diff --git a/CONTEXT.md b/CONTEXT.md index 31b6848d..eb869421 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -299,6 +299,10 @@ _Avoid_: Account, login, linked phone A short plain-text code, also drawn as a QR code, that adds a Remote Device. Either a trusted client shows a single-use code the new device reads, or the new device shows a code a trusted client reads and confirms. _Avoid_: Password, token, invite link +**SSH Computer**: +A Linux computer reached over SSH where an App Shell installs, starts, and keeps the App Server, and holds its token like a same-machine App Shell. It is not a Remote Device: no Pairing Code is involved. +_Avoid_: Remote host, server, SSH remote + **Desktop Update**: A newer official OpenAIDE Desktop App release offered inside the installed Desktop App. The user may review its Release Notes, initiate download and staging, and restart OpenAIDE to apply it. _Avoid_: Silently replacing a running release, treating a downloaded update as already applied diff --git a/apps/android/app/src/main/java/io/openaide/android/BackgroundService.java b/apps/android/app/src/main/java/io/openaide/android/BackgroundService.java index 9168cd53..774b3127 100644 --- a/apps/android/app/src/main/java/io/openaide/android/BackgroundService.java +++ b/apps/android/app/src/main/java/io/openaide/android/BackgroundService.java @@ -23,13 +23,14 @@ * The ongoing notification that keeps OpenAIDE working while it is off the screen. * * For the workspace on this phone it keeps the phone awake while a Task works and - * reads Task state from the local App Server. For a paired computer it holds no wake - * lock and does not stop when idle: it keeps a {@link RemoteAttention} watch, so Task - * notifications still arrive while the WebView is paused. The selected workspace - * decides the mode on every start, so a restart by the system resumes the same one. + * reads Task state from the local App Server. For a paired computer it does not stop + * when idle: it keeps a {@link RemoteAttention} watch, so Task notifications still + * arrive while the WebView is paused, and it also watches this phone's App Server if + * that is already running, so the phone's own Tasks keep it awake and notify too + * ({@link BackgroundSources}). The selected workspace decides the mode on every start, + * so a restart by the system resumes the same one. */ public final class BackgroundService extends Service { - private static final String TASK_TAG = "task:"; private static final int TASK_NOTIFICATION = 3; private WorkProtection protection; private final Handler handler = new Handler(Looper.getMainLooper()); @@ -40,7 +41,9 @@ public final class BackgroundService extends Service { private boolean polling; private final Runnable pollTask = this::poll; private long settlingUntil; - private final AttentionNotifications attention = new AttentionNotifications(); + /** Each source has its own baseline: the first list read from it stays silent. */ + private final AttentionNotifications localAttention = new AttentionNotifications(); + private final AttentionNotifications pairedAttention = new AttentionNotifications(); private long hiddenSince; private String notificationText = ""; private final AndroidDiagnostics diagnostics = new AndroidDiagnostics(); @@ -48,6 +51,10 @@ public final class BackgroundService extends Service { private String stopReason = "system"; /** The paired computer's watch, or null while the service serves the workspace on this phone. */ private RemoteAttention remote; + /** Whether this phone's own App Server is watched while the paired computer is in use. */ + private boolean watchingLocal; + /** Failed reads in a row of the phone's App Server while paired; only the transitions are logged. */ + private int localFailures; private boolean modeChosen; /** An outdated computer is reported once per process, not on every return to the app. */ private static boolean outdatedReported; @@ -102,13 +109,20 @@ private boolean chooseMode() { remote = new RemoteAttention(this, new RemoteAttention.Events() { @Override public void state(RemoteAttention.State state) { handler.post(() -> remoteState(state)); } @Override public void attention(List current) { - handler.post(() -> { if (!destroyed && remote != null) presentAttention(current); }); + handler.post(() -> { if (!destroyed && remote != null) presentAttention(BackgroundSources.Source.PAIRED, current); }); } }); remote.start(); + // The first read tells whether this phone's App Server runs; the watch never starts it. + watchingLocal = true; + localFailures = 0; + diagnostics.record("local_watch", "started", 0, 0, Map.of("source", BackgroundSources.Source.THIS_DEVICE.wire)); } else if (remote != null) { remote.stop(); remote = null; + endLocalWatch("mode_changed"); + // The phone's reads start over, so a long paired stretch is not taken for a lost server. + policy.observe(0, SystemClock.elapsedRealtime()); } diagnostics.record("background_work", paired ? "paired" : "local", 0, 0, Map.of("switched", switched)); return true; @@ -133,61 +147,97 @@ private void remoteState(RemoteAttention.State state) { stop("remote_" + state.name().toLowerCase(Locale.ROOT)); } + /** Reads this phone's App Server: the workspace in use, or alongside the paired computer while it is running. */ private void poll() { - if (polling || destroyed || remote != null) return; + boolean paired = remote != null; + if (polling || destroyed || (paired && !watchingLocal)) return; handler.removeCallbacks(pollTask); polling = true; worker.execute(() -> { ServerStatus status = null; - try { status = LocalServer.status(this); } + boolean running = true; + // The phone's App Server is started only while it is the workspace in use. + try { status = paired ? LocalServer.statusIfRunning() : LocalServer.status(this); running = status != null; } catch (Exception ignored) { } ServerStatus result = status; + boolean serverRunning = running; handler.post(() -> { polling = false; - // A switch to the paired computer while this read ran ends the local schedule. - if (destroyed || remote != null) return; + if (destroyed) return; + // A switch of the workspace while this read ran starts the schedule of the new mode. + if ((remote != null) != paired) { poll(); return; } long now = SystemClock.elapsedRealtime(); if (result != null) { policy.observe(result.active, now); - presentAttention(result.attention); - updateNotification(result.active > 0 ? "Working · safe to lock the screen" + presentAttention(BackgroundSources.Source.THIS_DEVICE, result.attention); + // While paired, the ongoing notification tells about the paired computer. + if (!paired) updateNotification(result.active > 0 ? "Working · safe to lock the screen" : result.waiting > 0 ? "Waiting for your response · battery saving" : "Ready · battery saving"); - } else updateNotification("Connection interrupted · checking again"); + } else if (!paired) updateNotification("Connection interrupted · checking again"); + if (paired) recordLocalRead(result != null, serverRunning); + BackgroundSources.Next next = BackgroundSources.afterLocalRead(paired, serverRunning, result, policy.expired(now), visible, now, settlingUntil); + if (next == BackgroundSources.Next.END_WATCH) { + endLocalWatch("not_running"); + policy.observe(0, now); + } updateLock(); - if (policy.expired(now)) { - getSystemService(NotificationManager.class).notify(2, notification("Connection lost. Open OpenAIDE to recover.", "completion")); - stop("connection_lost"); - } else if (!visible && result != null && result.active == 0 && now >= settlingUntil) stop("idle"); - else handler.postDelayed(pollTask, result == null ? 10_000 : result.active > 0 || !visible ? 5_000 : 15_000); + switch (next) { + case STOP_LOST: + getSystemService(NotificationManager.class).notify(2, notification("Connection lost. Open OpenAIDE to recover.", "completion")); + stop("connection_lost"); + break; + case STOP_IDLE: stop("idle"); break; + case END_WATCH: break; + default: handler.postDelayed(pollTask, BackgroundSources.delay(paired, result, visible)); + } }); }); } - /** Shows each new Task Attention Event that occurred out of sight and withdraws superseded ones. */ - private void presentAttention(List current) { + /** Logs the phone's reads while paired by transition only: a healthy schedule stays quiet. */ + private void recordLocalRead(boolean read, boolean running) { + if (!running) return; + if (!read && ++localFailures == 1) diagnostics.record("local_watch", "retrying", localFailures, 0, Map.of("source", BackgroundSources.Source.THIS_DEVICE.wire)); + if (read && localFailures > 0) { + diagnostics.record("local_watch", "recovered", localFailures, 0, Map.of("source", BackgroundSources.Source.THIS_DEVICE.wire)); + localFailures = 0; + } + } + + private void endLocalWatch(String reason) { + if (!watchingLocal) return; + watchingLocal = false; + diagnostics.record("local_watch", "ended", localFailures, 0, Map.of("source", BackgroundSources.Source.THIS_DEVICE.wire, "reason", reason)); + } + + /** Shows each new Task Attention Event of [source] that occurred out of sight and withdraws its superseded ones. */ + private void presentAttention(BackgroundSources.Source source, List current) { NotificationManager manager = getSystemService(NotificationManager.class); Map shown = new HashMap<>(); for (StatusBarNotification active : manager.getActiveNotifications()) { String tag = active.getTag(); String eventId = active.getNotification().extras.getString("event_id"); - if (tag != null && tag.startsWith(TASK_TAG) && eventId != null) shown.put(tag.substring(TASK_TAG.length()), eventId); + if (tag != null && tag.startsWith(source.tag) && eventId != null) shown.put(tag.substring(source.tag.length()), eventId); } List stale = AttentionNotifications.stale(shown, current); - for (String taskId : stale) manager.cancel(TASK_TAG + taskId, TASK_NOTIFICATION); - AttentionNotifications.Decision decision = attention.observe(current, visible, hiddenSince); + for (String taskId : stale) manager.cancel(source.tag + taskId, TASK_NOTIFICATION); + // The workspace not in use is never on the screen, so none of its events occurred in sight. + boolean inUse = BackgroundSources.inUse(source, remote != null); + AttentionNotifications attention = source == BackgroundSources.Source.PAIRED ? pairedAttention : localAttention; + AttentionNotifications.Decision decision = attention.observe(current, inUse && visible, inUse ? hiddenSince : 0); // Android drops a post the user blocked without telling the app, so the block is recorded instead. NotificationChannel channel = manager.getNotificationChannel("completion"); String blocked = !manager.areNotificationsEnabled() ? "app" : channel != null && channel.getImportance() == NotificationManager.IMPORTANCE_NONE ? "channel" : null; for (AttentionNotifications.Attention event : decision.show) { - manager.notify(TASK_TAG + event.taskId, TASK_NOTIFICATION, taskNotification(event)); + manager.notify(source.tag + event.taskId, TASK_NOTIFICATION, taskNotification(source, event)); diagnostics.record("task_attention_notification", blocked == null ? "shown" : "blocked", 1, 0, - blocked == null ? Map.of("reason", event.reason, "event_id", event.eventId) - : Map.of("reason", event.reason, "event_id", event.eventId, "blocked_by", blocked)); + blocked == null ? Map.of("reason", event.reason, "event_id", event.eventId, "source", source.wire) + : Map.of("reason", event.reason, "event_id", event.eventId, "blocked_by", blocked, "source", source.wire)); } - if (decision.baseline > 0) diagnostics.record("task_attention_notification", "suppressed", decision.baseline, 0, Map.of("cause", "baseline")); - if (decision.inSight > 0) diagnostics.record("task_attention_notification", "suppressed", decision.inSight, 0, Map.of("cause", "in_sight")); - if (!stale.isEmpty()) diagnostics.record("task_attention_notification", "withdrawn", stale.size(), 0); + if (decision.baseline > 0) diagnostics.record("task_attention_notification", "suppressed", decision.baseline, 0, Map.of("cause", "baseline", "source", source.wire)); + if (decision.inSight > 0) diagnostics.record("task_attention_notification", "suppressed", decision.inSight, 0, Map.of("cause", "in_sight", "source", source.wire)); + if (!stale.isEmpty()) diagnostics.record("task_attention_notification", "withdrawn", stale.size(), 0, Map.of("source", source.wire)); } private void stop(String reason) { @@ -195,10 +245,11 @@ private void stop(String reason) { stopSelf(); } - private Notification taskNotification(AttentionNotifications.Attention event) { - // A distinct request code per Task keeps each notification's target Task. - PendingIntent open = PendingIntent.getActivity(this, event.taskId.hashCode(), - new Intent(this, MainActivity.class).putExtra(MainActivity.EXTRA_TASK_ID, event.taskId), + private Notification taskNotification(BackgroundSources.Source source, AttentionNotifications.Attention event) { + // The source tells the tap whether the phone must become the workspace in use first. + PendingIntent open = PendingIntent.getActivity(this, source.requestCode(event.taskId), + new Intent(this, MainActivity.class).putExtra(MainActivity.EXTRA_TASK_ID, event.taskId) + .putExtra(BackgroundSources.EXTRA_TASK_SOURCE, source.wire), PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); android.os.Bundle extras = new android.os.Bundle(); extras.putString("event_id", event.eventId); @@ -208,8 +259,8 @@ private Notification taskNotification(AttentionNotifications.Attention event) { } private void updateLock() { - // A paired computer does the work; the phone may sleep while it waits for an answer. - protection.apply(remote == null && policy.protect(SystemClock.elapsedRealtime(), settlingUntil)); + // A paired computer does its own work; only this phone's working Tasks keep it awake. + protection.apply(BackgroundSources.protect(remote != null, watchingLocal, policy, SystemClock.elapsedRealtime(), settlingUntil)); } private void updateNotification(String text) { @@ -236,6 +287,7 @@ private Notification notification(String text, String channel) { handler.removeCallbacksAndMessages(null); worker.shutdownNow(); if (remote != null) remote.stop(); + endLocalWatch(stopReason); if (protection != null) protection.close(); stopForeground(STOP_FOREGROUND_REMOVE); // Task notifications end with the service, so its stop explains a later missing one. diff --git a/apps/android/app/src/main/java/io/openaide/android/BackgroundSources.java b/apps/android/app/src/main/java/io/openaide/android/BackgroundSources.java new file mode 100644 index 00000000..dab4af08 --- /dev/null +++ b/apps/android/app/src/main/java/io/openaide/android/BackgroundSources.java @@ -0,0 +1,83 @@ +package io.openaide.android; + +/** + * The rules for the two places the background service watches Tasks (ADR-0070). + * + * The workspace in use is always watched: this phone's App Server, or the paired + * computer through {@link RemoteAttention}. While the paired computer is in use the + * service also watches this phone's App Server, but only while it is already running, + * so the phone's own Tasks keep it awake and still notify. Each source keeps its own + * notifications, so one source's list never withdraws the other's, and a tap on a + * phone Task while the paired computer is in use moves to the phone before it opens + * the Task. Kept free of Android so the rules can be tested. + */ +final class BackgroundSources { + /** Which source a Task notification came from, carried in its tap intent. */ + static final String EXTRA_TASK_SOURCE = "task_source"; + + enum Source { + THIS_DEVICE("this_device", "task:"), + PAIRED("paired", "paired-task:"); + + /** The value carried in intents and diagnostics. */ + final String wire; + /** The notification tag prefix; each source withdraws only its own notifications. */ + final String tag; + + Source(String wire, String tag) { + this.wire = wire; + this.tag = tag; + } + + /** A distinct request code per source and Task keeps each notification's own target. */ + int requestCode(String taskId) { + return this == THIS_DEVICE ? taskId.hashCode() : (wire + ":" + taskId).hashCode(); + } + } + + /** What the service does after one read of this phone's App Server. */ + enum Next { RESCHEDULE, STOP_IDLE, STOP_LOST, END_WATCH } + + /** What a tap on a Task notification does. */ + enum Tap { OPEN, SWITCH_THEN_OPEN } + + private BackgroundSources() { } + + /** + * The step after a read of this phone's App Server. With the phone in use the + * service ends with its work, or when the server stays unreachable. With the + * paired computer in use that computer's watch keeps the service, so the phone's + * watch only ends once its server is gone, and a failed read is retried. + */ + static Next afterLocalRead(boolean paired, boolean running, ServerStatus result, boolean expired, boolean visible, long now, long settlingUntil) { + if (paired) return running ? Next.RESCHEDULE : Next.END_WATCH; + if (expired) return Next.STOP_LOST; + if (!visible && result != null && result.active == 0 && now >= settlingUntil) return Next.STOP_IDLE; + return Next.RESCHEDULE; + } + + /** When to read this phone's App Server again; idle phone Tasks are read seldom while the paired computer is in use. */ + static long delay(boolean paired, ServerStatus result, boolean visible) { + if (result == null) return 10_000; + if (result.active > 0) return 5_000; + if (paired) return 30_000; + return visible ? 15_000 : 5_000; + } + + /** Whether the phone stays awake: only for its own working Tasks; a paired computer does its work itself. */ + static boolean protect(boolean paired, boolean watchingLocal, PowerPolicy policy, long now, long settlingUntil) { + if (!paired) return policy.protect(now, settlingUntil); + // Leaving the screen gives no settling time here: the read that follows decides. + return watchingLocal && policy.protect(now, 0); + } + + /** Only the workspace in use is on the screen; the other source's events are never in sight. */ + static boolean inUse(Source source, boolean paired) { + return (source == Source.PAIRED) == paired; + } + + /** A phone Task tapped while the paired computer is in use opens once the phone is in use. */ + static Tap tap(String source, boolean pairedInUse) { + return pairedInUse && Source.THIS_DEVICE.wire.equals(source) ? Tap.SWITCH_THEN_OPEN : Tap.OPEN; + } +} diff --git a/apps/android/app/src/main/java/io/openaide/android/ConnectionController.java b/apps/android/app/src/main/java/io/openaide/android/ConnectionController.java index 18bb2787..ad269ed8 100644 --- a/apps/android/app/src/main/java/io/openaide/android/ConnectionController.java +++ b/apps/android/app/src/main/java/io/openaide/android/ConnectionController.java @@ -38,18 +38,20 @@ interface View { private int operationSequence; private String operationName; private JSONObject joinCode; + private final ComputerSwitch switcher; ConnectionController(Activity activity, View view, boolean localTools) { super(activity); this.activity = activity; this.view = view; this.localTools = localTools; + switcher = RemotePairing.computerSwitch(activity, () -> { if (!isDestroyed()) changed(); }); } void receive(JSONObject message) { if (disposed) return; String command = message.optString("action"); - if (!localTools && !java.util.Arrays.asList("state", "close", "local", "use_computer", "forget_computer", "pair_setup", "app_settings", "diagnostics").contains(command)) { + if (!localTools && !java.util.Arrays.asList("state", "close", "pair_setup", "app_settings", "diagnostics").contains(command)) { notice = "Switch to this phone before managing local tools."; emit(); return; @@ -58,6 +60,36 @@ void receive(JSONObject message) { catch (Exception error) { notice = "That action could not finish. Please try again."; emit(); } } + /** + * Switches the computer in use, connecting first (ADR-0070); [reply] answers + * the page once, before a switch that took effect replaces it. + */ + void switchComputer(JSONObject message, ComputerSwitch.Reply reply) { + if (disposed) { reply.done(null, "Phone settings are unavailable. Reopen Settings and try again."); return; } + String action = message.optString("action"); + if (action.equals("cancel_switch")) { reply.done(switcher.cancel() ? "cancelled" : "unchanged", null); return; } + ConnectionStore store = new ConnectionStore(this); + ConnectionStore.PairedServer selected = store.usesPaired() ? store.pairedServer() : null; + String target = action.equals("use_computer") ? message.optString("id") : null; + String operationId = message.has("operationId") ? message.optString("operationId") : null; + if (action.equals("forget_computer")) { forget(message.optString("id"), operationId, reply); return; } + switcher.request(selected == null ? null : selected.id, target, operationId, reply); + } + + /** Forgets a paired computer; the one in use is left through the switch first, and forgotten only once it took effect. */ + private void forget(String id, String operationId, ComputerSwitch.Reply reply) { + ComputerForget.Pairings pairings = new ComputerForget.Pairings() { + @Override public String inUse() { + ConnectionStore store = new ConnectionStore(ConnectionController.this); + ConnectionStore.PairedServer selected = store.usesPaired() ? store.pairedServer() : null; + return selected == null ? null : selected.id; + } + @Override public void forget(String forgotten) { new ConnectionStore(ConnectionController.this).forgetPaired(forgotten); } + }; + ComputerForget.INSTANCE.forget(id, operationId, pairings, switcher, + (outcome, fields) -> diagnostics.record("connection_computer_forgotten", outcome, 0, 0, fields), this::emit, reply); + } + private void action(JSONObject message) throws Exception { String action = message.optString("action"); if (action.equals("state")) { emit(); return; } @@ -67,30 +99,11 @@ private void action(JSONObject message) throws Exception { case "close": view.close(); break; case "state": emit(); break; case "check": check(); break; - case "local": new ConnectionStore(this).selectLocal(); changed(); break; - case "use_computer": - if (!new ConnectionStore(this).selectPaired(message.optString("id"))) { emit(); break; } - // A connection to the computer used until now must not outlive the choice. - RemoteNode.INSTANCE.disconnect(); - diagnostics.record("connection_computer_selected", "selected", 0, 0); - changed(); - break; case "pair_setup": view.setup("remote"); break; case "scan": scan(); break; case "pair": pair(message.optString("code")); break; case "join": join(); break; case "join_stop": RemotePairing.stopJoin(); joinCode = null; emit(); break; - case "forget_computer": { - ConnectionStore store = new ConnectionStore(this); - String id = message.optString("id"); - ConnectionStore.PairedServer selected = store.pairedServer(); - boolean current = store.usesPaired() && selected != null && selected.id.equals(id); - store.forgetPaired(id); - diagnostics.record("connection_computer_forgotten", current ? "current" : "other", 0, 0); - // Only leaving the computer in use changes the workspace; the list alone redraws. - if (current) { RemoteNode.INSTANCE.disconnect(); changed(); } else emit(); - break; - } case "install": install(); break; case "termux": launchTermux(); break; case "get_termux": open(new Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/termux/termux-app/releases"))); break; @@ -282,6 +295,9 @@ void onActivityResult(int request, int result, Intent data) { if (code != null) pair(code); } + /** Sends the current state without rechecking this phone's setup. */ + void refresh() { emit(); } + void resume() { if (recheckOnReturn && !busy) { recheckOnReturn = false; check(); } else emit(); @@ -289,6 +305,8 @@ void resume() { void dispose() { if (busy) diagnostics.record(operationName, "view_closed", operationSequence, SystemClock.elapsedRealtime() - operationStarted); disposed = true; + // A page that goes away mid-switch cannot be answered; the switch ends with it. + switcher.cancel(); RemotePairing.stopJoin(); worker.shutdownNow(); } diff --git a/apps/android/app/src/main/java/io/openaide/android/MainActivity.java b/apps/android/app/src/main/java/io/openaide/android/MainActivity.java index 6bbb8d49..b907e9d0 100644 --- a/apps/android/app/src/main/java/io/openaide/android/MainActivity.java +++ b/apps/android/app/src/main/java/io/openaide/android/MainActivity.java @@ -33,6 +33,7 @@ import java.io.IOException; import java.io.ByteArrayInputStream; import java.util.Collections; +import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -73,6 +74,8 @@ public final class MainActivity extends Activity { /** Set by a Task notification; the workspace routes to it once its page is loaded. */ static final String EXTRA_TASK_ID = "task_id"; private String pendingTask; + /** The source of [pendingTask]'s notification; a phone Task opens once the phone is in use. */ + private String pendingTaskSource; private BackNavigation backNavigation; @Override public void onCreate(Bundle savedState) { @@ -87,6 +90,7 @@ public final class MainActivity extends Activity { AppUpdateJob.schedule(this); AppUpdates.get(this).checkIfDue(); pendingTask = getIntent().getStringExtra(EXTRA_TASK_ID); + pendingTaskSource = getIntent().getStringExtra(BackgroundSources.EXTRA_TASK_SOURCE); if (credentialsUnavailable) openSetup("welcome"); else if (getIntent().getBooleanExtra("show_settings", false)) showSettings("remote"); else if (getIntent().getBooleanExtra(AppUpdates.EXTRA_SHOW, false)) showSettings("general"); @@ -182,12 +186,40 @@ private void navigateToConnectionSettings() { private void navigateToTask() { if (browser == null || pendingTask == null) return; + if (BackgroundSources.tap(pendingTaskSource, !profile.local) == BackgroundSources.Tap.SWITCH_THEN_OPEN) { + switchToOpenTask(); + return; + } String path = "/task/" + Uri.encode(pendingTask); pendingTask = null; diagnostics.record("task_notification", "opened", 0, 0); browser.evaluateJavascript("history.pushState(null, '', " + org.json.JSONObject.quote(path) + "); window.dispatchEvent(new PopStateEvent('popstate'))", null); } + /** + * A phone Task tapped while the paired computer is in use: the phone becomes the + * workspace in use through the connect-first switch, as the switcher does, and the + * Task opens on the page that replaces this one. A switch that fails changes nothing. + */ + private void switchToOpenTask() { + if (connectionBridge == null) return; + String operationId = ComputerSwitch.Companion.operationId(null); + Map fields = Map.of("operation_id", operationId, "source", BackgroundSources.Source.THIS_DEVICE.wire); + long started = SystemClock.elapsedRealtime(); + int attempt = generation; + String task = pendingTask; + // The page that replaces this one opens the Task; the source is not needed any longer. + pendingTaskSource = null; + diagnostics.record("task_notification", "switch_requested", 0, 0, fields); + connectionBridge.useThisPhone(operationId, (outcome, problem) -> { + diagnostics.record("task_notification", problem != null ? "switch_failed" : "switch_" + outcome, 0, SystemClock.elapsedRealtime() - started, fields); + if (problem != null || "cancelled".equals(outcome)) { + if (task.equals(pendingTask)) pendingTask = null; + if (problem != null && !isDestroyed()) Toast.makeText(this, problem, Toast.LENGTH_LONG).show(); + } else if ("unchanged".equals(outcome) && attempt == generation) navigateToTask(); + }); + } + private void openSetup(String screen) { if (settingsOpen) return; settingsOpen = true; @@ -232,6 +264,7 @@ private void startBackgroundWork() { else if (intent.getBooleanExtra(AppUpdates.EXTRA_SHOW, false)) showSettings("general"); else if (intent.getStringExtra(EXTRA_TASK_ID) != null) { pendingTask = intent.getStringExtra(EXTRA_TASK_ID); + pendingTaskSource = intent.getStringExtra(BackgroundSources.EXTRA_TASK_SOURCE); // A shown page routes now; otherwise the page's load or the reconnect below routes it. if (browser != null && profile.owns(browser.getUrl())) navigateToTask(); } diff --git a/apps/android/app/src/main/java/io/openaide/android/SetupPagePolicy.java b/apps/android/app/src/main/java/io/openaide/android/SetupPagePolicy.java index 040cc027..d47cd016 100644 --- a/apps/android/app/src/main/java/io/openaide/android/SetupPagePolicy.java +++ b/apps/android/app/src/main/java/io/openaide/android/SetupPagePolicy.java @@ -17,6 +17,23 @@ static boolean opensSettings(ConnectionProfile profile, String source, String de /** Settings sections that show phone controls: background work, paired computers, phone setup. */ private static final java.util.Set PHONE_SECTIONS = java.util.Set.of("general", "remote", "troubleshooting"); + /** + * Switching the computer in use works from every page, so the switcher in the Task + * navigation needs no Settings route (ADR-0070). Every other connection command + * changes this phone's setup and stays limited to the Settings sections. + */ + private static final java.util.Set EVERY_PAGE_ACTIONS = java.util.Set.of("use_computer", "local", "cancel_switch"); + + /** + * Switch commands are answered when the switch ends, not when they are accepted. + * Forgetting is one: forgetting the computer in use moves to this phone first. + */ + private static final java.util.Set SWITCH_ACTIONS = java.util.Set.of("use_computer", "local", "cancel_switch", "forget_computer"); + + static boolean switchesComputer(String action) { return SWITCH_ACTIONS.contains(action); } + + static boolean allowedOnEveryPage(String action) { return EVERY_PAGE_ACTIONS.contains(action); } + static boolean isConnectionRoute(ConnectionProfile profile, String address) { if (!profile.owns(address)) return false; try { diff --git a/apps/android/app/src/main/java/io/openaide/android/WorkspaceConnectionBridge.java b/apps/android/app/src/main/java/io/openaide/android/WorkspaceConnectionBridge.java index 3f22332f..37c97fd7 100644 --- a/apps/android/app/src/main/java/io/openaide/android/WorkspaceConnectionBridge.java +++ b/apps/android/app/src/main/java/io/openaide/android/WorkspaceConnectionBridge.java @@ -31,7 +31,11 @@ final class WorkspaceConnectionBridge { this.visible = visible; appearance = new SystemBarAppearance(activity, browser); controller = new ConnectionController(activity, new ConnectionController.View() { - @Override public void render(JSONObject state) { if (settingsRoute()) send("state", "state", state); } + @Override public void render(JSONObject state) { + // Every page gets the computers for the switcher; only Settings gets the setup state. + send("computers", "computers", state.optJSONArray("computers")); + if (settingsRoute()) send("state", "state", state); + } @Override public void changed() { changed.run(); } @Override public void close() { } @Override public void setup(String screen) { setup.accept(screen); } @@ -53,14 +57,23 @@ void attach() { return; } int id = request.getInt("id"); + JSONObject command = request.getJSONObject("command"); long started = android.os.SystemClock.elapsedRealtime(); diagnostics.record("connection_controls", "started", id, 0); - if (!visible.getAsBoolean() || !settingsRoute()) { + if (!visible.getAsBoolean() || (!settingsRoute() && !SetupPagePolicy.allowedOnEveryPage(command.optString("action")))) { diagnostics.record("connection_controls", "denied", id, android.os.SystemClock.elapsedRealtime() - started); reply(id, false); return; } - JSONObject command = request.getJSONObject("command"); + if (SetupPagePolicy.switchesComputer(command.optString("action"))) { + WebMessagePort asked = port; + controller.switchComputer(command, (outcome, problem) -> { + // A later page numbers its requests anew; only the page that asked is answered. + if (port == asked) answer(id, outcome, problem); + }); + diagnostics.record("connection_controls", "switch_requested", id, android.os.SystemClock.elapsedRealtime() - started); + return; + } if (command.optString("action").startsWith("update_")) updates.command(activity, command); else controller.receive(command); diagnostics.record("connection_controls", "accepted", id, android.os.SystemClock.elapsedRealtime() - started); @@ -71,6 +84,7 @@ void attach() { browser.postWebMessage(new WebMessage("openaide:connection-controls:1", new WebMessagePort[]{channel[1]}), Uri.parse(profile.endpoint.substring(0, profile.endpoint.length() - 1))); if (settingsRoute()) controller.resume(); + else controller.refresh(); // Every workspace page gets the update state, so navigation can mark an available update. updatesSubscription = updates.subscribe(state -> send("update", "update", state)); } @@ -89,7 +103,30 @@ private void reply(int id, boolean accepted) { catch (Exception ignored) { } } - void resume() { if (settingsRoute()) controller.resume(); } + /** The end of a switch: its outcome, or the sentence saying why nothing changed. */ + private void answer(int id, String outcome, String problem) { + if (port == null) return; + try { + JSONObject result = new JSONObject().put("type", "result").put("id", id).put("accepted", problem == null); + if (problem == null) result.put("outcome", outcome); + else result.put("error", problem); + port.postMessage(new WebMessage(result.toString())); + } catch (Exception ignored) { } + } + + /** + * Makes this phone the workspace in use through the connect-first switch, as the + * switcher does, for a tap on one of its Task notifications; [reply] answers once. + */ + void useThisPhone(String operationId, ComputerSwitch.Reply reply) { + try { controller.switchComputer(new JSONObject().put("action", "local").put("operationId", operationId), reply); } + catch (org.json.JSONException impossible) { reply.done(null, "OpenAIDE could not switch. Try again."); } + } + + void resume() { + if (settingsRoute()) controller.resume(); + else controller.refresh(); + } void onRequestPermissionsResult(int request, String[] permissions, int[] results) { controller.onRequestPermissionsResult(request, permissions, results); } void onActivityResult(int request, int result, Intent data) { controller.onActivityResult(request, result, data); } void detach() { diff --git a/apps/android/app/src/main/kotlin/io/openaide/android/ComputerForget.kt b/apps/android/app/src/main/kotlin/io/openaide/android/ComputerForget.kt new file mode 100644 index 00000000..04476d5e --- /dev/null +++ b/apps/android/app/src/main/kotlin/io/openaide/android/ComputerForget.kt @@ -0,0 +1,48 @@ +package io.openaide.android + +/** + * Forgetting a paired computer (ADR-0070). Forgetting the computer in use moves the + * phone to itself through the connect-first [ComputerSwitch] and forgets the pairing + * only once that switch took effect: a Cancel keeps the pairing, and a failure answers + * why and changes nothing. Forgetting a computer not in use is immediate. + * + * Called on the main thread; kept free of Android so the rules can be tested. + */ +internal object ComputerForget { + /** The phone's pairings as forgetting needs them. */ + interface Pairings { + /** The paired computer in use, or null while this phone is. */ + fun inUse(): String? + fun forget(id: String) + } + + fun interface Log { fun record(outcome: String, fields: Map) } + + /** Forgets [id]; [redraw] shows the shorter list when the page stays, and [reply] answers once. */ + fun forget(id: String, operationId: String?, pairings: Pairings, switch: ComputerSwitch, log: Log, redraw: Runnable, reply: ComputerSwitch.Reply) { + val inUse = pairings.inUse() + if (inUse != id) { + pairings.forget(id) + log.record("other", emptyMap()) + redraw.run() + reply.done("forgotten", null) + return + } + // The switch logs under the same id, so its events and the forget correlate. + val operation = ComputerSwitch.operationId(operationId) + val fields = mapOf("operation_id" to operation) + switch.request(inUse, null, operation) { outcome, problem -> + if (outcome != "switched") { + // Cancelled, or not switched: the computer stays paired and in use. + if (problem == null) log.record("kept", fields + ("cause" to (outcome ?: "unknown"))) + reply.done(outcome, problem) + return@request + } + val forgotten = runCatching { pairings.forget(id) }.isSuccess + if (forgotten) log.record("current", fields) else log.record("failed", fields + ("error_class" to "store")) + // The switch replaces the page after this answer. + if (forgotten) reply.done("switched", null) + else reply.done(null, "This phone is now in use, but OpenAIDE could not forget that computer. Try again.") + } + } +} diff --git a/apps/android/app/src/main/kotlin/io/openaide/android/ComputerSwitch.kt b/apps/android/app/src/main/kotlin/io/openaide/android/ComputerSwitch.kt new file mode 100644 index 00000000..7d2f6eae --- /dev/null +++ b/apps/android/app/src/main/kotlin/io/openaide/android/ComputerSwitch.kt @@ -0,0 +1,138 @@ +package io.openaide.android + +import java.util.UUID +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * The phone's one switch of the computer in use (ADR-0070). It connects the + * target while the computer in use stays in use, and changes nothing until that + * connection is made: a failure or a Cancel leaves the selection, the live + * connection and the page as they were. Once connected it saves the choice, + * answers the page, and only then replaces the page, so the answer is not lost + * with it. + * + * Owned by one page's controller and called on the main thread; [main] brings + * the connect's result back there. Kept free of Android and of the library so + * the rules can be tested. + */ +internal class ComputerSwitch( + private val scope: CoroutineScope, + private val main: (() -> Unit) -> Unit, + private val clock: () -> Long, + private val log: Log, + private val steps: Steps, +) { + /** What a switch does to the phone; `null` as a target is this phone. */ + interface Steps { + /** Reaches the target without touching the computer in use; throws [Failed] with a sentence for the user. */ + suspend fun connect(target: String?): C + /** Saves the choice and moves the phone's connection to what [connect] reached. */ + fun apply(target: String?, connected: C) + /** Gives up what [connect] reached for a switch that no longer applies. */ + fun discard(connected: C) + /** Replaces the page with the computer now in use. */ + fun replacePage() + } + + fun interface Reply { fun done(outcome: String?, problem: String?) } + + fun interface Log { fun record(event: String, outcome: String, durationMs: Long, fields: Map) } + + /** A target that could not be reached: a stable class for diagnostics, and a sentence for the user. */ + class Failed(val errorClass: String, message: String) : Exception(message) + + private class Pending(val operationId: String, val fields: Map, val started: Long, val reply: Reply) { + var job: Job? = null + } + + private var pending: Pending? = null + + val switching: Boolean get() = pending != null + + /** Switches from [source] to [target]; [reply] answers once with `switched`, `unchanged`, `cancelled`, or a problem. */ + fun request(source: String?, target: String?, operationId: String?, reply: Reply) { + if (pending != null) { + reply.done(null, "Another computer is still connecting. Wait for it, or cancel it first.") + return + } + if (source == target) { + reply.done("unchanged", null) + return + } + val fields = mapOf( + "operation_id" to operationId(operationId), + "source_kind" to kind(source), + "target_kind" to kind(target), + ) + val switch = Pending(fields.getValue("operation_id"), fields, clock(), reply) + pending = switch + log.record("computer_switch", "started", 0, fields) + switch.job = scope.launch { + val result = try { + Result.success(steps.connect(target)) + } catch (error: CancellationException) { + // A Cancel already answered the page; a timeout that escaped the connect did not. + if (!isActive) return@launch + Result.failure(error) + } catch (error: Throwable) { + Result.failure(error) + } + main { finish(switch, target, result) } + } + } + + /** Ends the switch still connecting; the page is answered `cancelled`. False when nothing was connecting. */ + fun cancel(): Boolean { + val switch = pending ?: return false + pending = null + switch.job?.cancel() + log.record("computer_switch", "cancelled", clock() - switch.started, switch.fields) + switch.reply.done("cancelled", null) + return true + } + + private fun finish(switch: Pending, target: String?, result: Result) { + if (pending !== switch) { + // Cancelled after the target answered: what it reached is not used. + result.onSuccess { runCatching { steps.discard(it) } } + return + } + pending = null + val elapsed = clock() - switch.started + val problem = result.fold( + onSuccess = { connected -> + try { + steps.apply(target, connected) + null + } catch (error: Throwable) { + runCatching { steps.discard(connected) } + Failed("apply", "OpenAIDE could not save that choice. Try again.") + } + }, + onFailure = { it as? Failed ?: Failed("unexpected", "OpenAIDE could not switch. Try again.") }, + ) + if (problem != null) { + log.record("computer_switch", "failed", elapsed, switch.fields + ("error_class" to problem.errorClass)) + switch.reply.done(null, problem.message) + return + } + log.record("computer_switch", "switched", elapsed, switch.fields) + switch.reply.done("switched", null) + // The answer is posted before the page that would receive it is replaced. + main { steps.replacePage() } + } + + companion object { + private val SAFE_ID = Regex("[A-Za-z0-9_-]{1,80}") + + /** The page's correlation id when it is plainly safe to log, else a new one. */ + fun operationId(candidate: String?): String = + candidate?.takeIf { SAFE_ID.matches(it) } ?: "android-computer-switch-${UUID.randomUUID()}" + + private fun kind(target: String?) = if (target == null) "this_device" else "paired" + } +} diff --git a/apps/android/app/src/main/kotlin/io/openaide/android/LocalServer.kt b/apps/android/app/src/main/kotlin/io/openaide/android/LocalServer.kt index 6999fc71..78f60cf8 100644 --- a/apps/android/app/src/main/kotlin/io/openaide/android/LocalServer.kt +++ b/apps/android/app/src/main/kotlin/io/openaide/android/LocalServer.kt @@ -107,6 +107,18 @@ internal object LocalServer { @JvmStatic @Throws(IOException::class) fun status(context: Context): ServerStatus { val server = try { endpoint(context) } catch (error: Unavailable) { throw IOException("Local App Server unavailable") } + return status(server) + } + + /** + * [status] of a server this process already reaches, or null when there is none. + * Never starts one: watching this phone's Tasks while a paired computer is in + * use must not launch Termux (ADR-0070). + */ + @JvmStatic @Throws(IOException::class) + fun statusIfRunning(): ServerStatus? = endpoint?.let(::status) + + private fun status(server: Endpoint): ServerStatus { var active = 0 var waiting = 0 var cursor: Any? = null diff --git a/apps/android/app/src/main/kotlin/io/openaide/android/ReconnectPolicy.kt b/apps/android/app/src/main/kotlin/io/openaide/android/ReconnectPolicy.kt index a225a571..3e1b2861 100644 --- a/apps/android/app/src/main/kotlin/io/openaide/android/ReconnectPolicy.kt +++ b/apps/android/app/src/main/kotlin/io/openaide/android/ReconnectPolicy.kt @@ -26,6 +26,13 @@ internal object ReconnectPolicy { fun staleAfterHidden(hiddenMs: Long): Boolean = hiddenMs > STALE_AFTER_HIDDEN_MS + /** + * Whether a failed dial gives the next attempt a new endpoint. Not while the + * endpoint carries the connection in use, as when a switch to another computer + * dials first: retiring it would end the computer the user is still on. + */ + fun retireAfterFailedDial(carriesLiveConnection: Boolean): Boolean = !carriesLiveConnection + /** How long attempt [index] (from zero) may take after [spentMs] of the budget, or zero for no further attempt. */ fun attemptTimeoutMs(index: Int, spentMs: Long): Long { if (index < 0 || index >= ATTEMPT_TIMEOUTS_MS.size) return 0 diff --git a/apps/android/app/src/main/kotlin/io/openaide/android/RemoteNode.kt b/apps/android/app/src/main/kotlin/io/openaide/android/RemoteNode.kt index 96b4a3b2..8ccf971c 100644 --- a/apps/android/app/src/main/kotlin/io/openaide/android/RemoteNode.kt +++ b/apps/android/app/src/main/kotlin/io/openaide/android/RemoteNode.kt @@ -11,6 +11,7 @@ import computer.iroh.EndpointOptions import computer.iroh.IrohAndroid import computer.iroh.SecretKey import computer.iroh.presetN0 +import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -182,10 +183,39 @@ internal object RemoteNode { live()?.let { return it } val store = ConnectionStore(context.applicationContext) val server = store.pairedServer() ?: throw Unavailable(Failure.NOT_TRUSTED) - reach(context, store, server) + reach(context, store, server).also { use(it) } } } + /** + * Reaches [server] for a switch while the connection in use stays live: the + * result is not used until [adopt] takes it, and a failed attempt keeps the + * endpoint that connection rides on. + */ + suspend fun reachForSwitch(context: Context, server: ConnectionStore.PairedServer): Connection = + reach(context, ConnectionStore(context.applicationContext), server) + + /** Makes a connection reached for a switch the one in use, ending the previous one. */ + fun adopt(next: Connection) { + val previous = connection + use(next) + if (previous != null && previous !== next) runCatching { previous.close(0L, "switched".toByteArray()) } + diagnostics.record("remote_connect", "adopted", attempts, 0) + } + + /** Makes [opened] the live connection; its receive watch lasts while it stays live. */ + private fun use(opened: Connection) { + connection = opened + watchReceive(opened, attempts) + } + + /** Ends a connection reached for a switch that was cancelled or could not be saved. */ + fun abandon(unused: Connection) { + if (connection === unused) return + runCatching { unused.close(0L, "cancelled".toByteArray()) } + diagnostics.record("remote_connect", "abandoned", attempts, 0) + } + /** * One slow or stalled dial is not yet an unreachable computer: each further * attempt starts on a new endpoint, and the caller keeps showing "Connecting…". @@ -202,7 +232,12 @@ internal object RemoteNode { // A hidden app usually has no network to retry on; its return starts over anyway. val more = hiddenAt == 0L && ReconnectPolicy.attemptTimeoutMs(index, SystemClock.elapsedRealtime() - started) > 0 if (error.failure != Failure.UNREACHABLE || !more) throw error - stale = "dial_failed" + // TODO: a dial for a switch shares the one endpoint with the live connection, so the + // live connection's own reconnect (a return to the screen, a stalled receive) retires + // that endpoint and ends the switch's dial mid-attempt, which the user sees as a failed + // switch to a reachable computer. Give the switch's dial its own endpoint, or have that + // reconnect stand down while a switch is pending. + if (ReconnectPolicy.retireAfterFailedDial(live() != null)) stale = "dial_failed" } } } @@ -229,14 +264,16 @@ internal object RemoteNode { // so a removed phone learns it from the close that follows. val refusal = withTimeoutOrNull(REFUSAL_WAIT_MS) { opened.closed() } if (refusal != null) throw Unavailable(failure(refusal)) - connection = opened - watchReceive(opened, attempt) outcome = if (opened.paths().any { it.isSelected && it.isRelay }) "relay" else "direct" rememberRelay(node, store, id, server.id, relay, attempt) return opened } catch (error: Unavailable) { if (outcome != "timeout") outcome = error.failure.name.lowercase() throw error + } catch (error: CancellationException) { + // A cancelled switch, not an unreachable computer. + outcome = "cancelled" + throw error } catch (error: Throwable) { // An App Server of another protocol major does not offer this ALPN. val incompatible = (error.message ?: "").contains("alpn", ignoreCase = true) diff --git a/apps/android/app/src/main/kotlin/io/openaide/android/RemotePairing.kt b/apps/android/app/src/main/kotlin/io/openaide/android/RemotePairing.kt index 25b13caf..c5a9157c 100644 --- a/apps/android/app/src/main/kotlin/io/openaide/android/RemotePairing.kt +++ b/apps/android/app/src/main/kotlin/io/openaide/android/RemotePairing.kt @@ -4,10 +4,12 @@ import android.content.Context import android.os.Build import android.os.Handler import android.os.Looper +import android.os.SystemClock import android.provider.Settings import com.google.zxing.BarcodeFormat import com.google.zxing.EncodeHintType import com.google.zxing.qrcode.QRCodeWriter +import computer.iroh.Connection import kotlinx.coroutines.launch import org.json.JSONObject @@ -83,6 +85,54 @@ internal object RemotePairing { } } + /** + * The phone's steps for one page's [ComputerSwitch]: reach this phone's own App + * Server or a paired computer's while the one in use stays live, then save the + * choice and move the connection to it. The star keeps the iroh type out of the + * Java signature: CI's Java 17 compiler cannot read iroh's newer class files. + */ + @JvmStatic fun computerSwitch(context: Context, replacePage: Runnable): ComputerSwitch<*> { + val application = context.applicationContext + val diagnostics = AndroidDiagnostics() + return ComputerSwitch(RemoteNode.scope, { main.post(it) }, SystemClock::elapsedRealtime, + { event, outcome, durationMs, fields -> diagnostics.record(event, outcome, 0, durationMs, fields) }, + object : ComputerSwitch.Steps { + override suspend fun connect(target: String?): Connection? { + if (!WorkspaceGateway.start(application, target == null)) { + throw ComputerSwitch.Failed("gateway", "OpenAIDE could not start its connection on this phone. Restart the app and try again.") + } + if (target == null) { + // Starting the App Server in Termux cannot be interrupted; a Cancel ignores its result. + try { LocalServer.endpoint(application) } catch (error: LocalServer.Unavailable) { + throw ComputerSwitch.Failed("local_unavailable", "Your local workspace could not start. Try again, or check this phone in Connection settings.") + } + return null + } + val server = PairedServers.find(ConnectionStore(application).pairedServers(), target) + ?: throw ComputerSwitch.Failed("not_paired", "This phone is no longer paired with that computer.") + return try { RemoteNode.reachForSwitch(application, server) } catch (error: RemoteNode.Unavailable) { + throw ComputerSwitch.Failed(error.failure.name.lowercase(), message(error.failure)) + } + } + + override fun apply(target: String?, connected: Connection?) { + val store = ConnectionStore(application) + if (target == null) { + store.selectLocal() + // The computer left behind keeps its work; only this phone's link to it ends. + RemoteNode.disconnect() + } else { + check(store.selectPaired(target)) + RemoteNode.adopt(connected!!) + } + } + + override fun discard(connected: Connection?) { connected?.let(RemoteNode::abandon) } + + override fun replacePage() = replacePage.run() + }) + } + @JvmStatic fun message(failure: RemoteNode.Failure): String = when (failure) { RemoteNode.Failure.UNREACHABLE -> "Your computer is unreachable. Make sure OpenAIDE is open on it and both devices are online." RemoteNode.Failure.NOT_TRUSTED -> "This phone was removed from your computer. Pair it again in Connection settings." diff --git a/apps/android/app/src/test/java/io/openaide/android/BackgroundSourcesTest.java b/apps/android/app/src/test/java/io/openaide/android/BackgroundSourcesTest.java new file mode 100644 index 00000000..09fe0dc4 --- /dev/null +++ b/apps/android/app/src/test/java/io/openaide/android/BackgroundSourcesTest.java @@ -0,0 +1,93 @@ +package io.openaide.android; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.junit.Test; +import static org.junit.Assert.*; + +public class BackgroundSourcesTest { + private static final BackgroundSources.Source PHONE = BackgroundSources.Source.THIS_DEVICE; + private static final BackgroundSources.Source PAIRED = BackgroundSources.Source.PAIRED; + + @Test public void eachSourceKeepsItsOwnNotificationsAndTargets() { + assertNotEquals(PHONE.tag, PAIRED.tag); + assertFalse(PHONE.tag.startsWith(PAIRED.tag) || PAIRED.tag.startsWith(PHONE.tag)); + // The phone's notifications keep the target they had before the paired source existed. + assertEquals("task-1".hashCode(), PHONE.requestCode("task-1")); + assertNotEquals(PHONE.requestCode("task-1"), PAIRED.requestCode("task-1")); + // A list read from one source never withdraws the other's notification of the same Task id. + Map shownByPhone = Map.of("task-1", "phone-event"); + assertTrue(AttentionNotifications.stale(shownByPhone, List.of(event("task-1", "phone-event"))).isEmpty()); + } + + @Test public void thePhonesWatchWhilePairedStartsWithItsOwnSilentBaseline() { + AttentionNotifications paired = new AttentionNotifications(); + AttentionNotifications phone = new AttentionNotifications(); + paired.observe(Collections.emptyList(), false, 0); + + AttentionNotifications.Decision first = phone.observe(List.of(event("task-1", "event-1")), false, 0); + assertTrue(first.show.isEmpty()); + assertEquals(1, first.baseline); + // The paired computer's baseline was already read, so its new event notifies. + assertEquals(1, paired.observe(List.of(event("task-2", "event-2")), false, 0).show.size()); + } + + @Test public void onlyTheWorkspaceInUseIsOnTheScreen() { + assertTrue(BackgroundSources.inUse(PAIRED, true)); + assertFalse(BackgroundSources.inUse(PHONE, true)); + assertTrue(BackgroundSources.inUse(PHONE, false)); + assertFalse(BackgroundSources.inUse(PAIRED, false)); + } + + @Test public void phoneTasksKeepThePhoneAwakeWhilePaired() { + PowerPolicy policy = new PowerPolicy(0); + policy.observe(1, 1_000); + assertTrue(BackgroundSources.protect(true, true, policy, 2_000, 0)); + // Once the phone's watch ended, nothing on the phone works. + assertFalse(BackgroundSources.protect(true, false, policy, 2_000, 0)); + // Leaving the screen while paired gives no settling wake lock without a working phone Task. + policy.observe(0, 1_000); + assertFalse(BackgroundSources.protect(true, true, policy, 2_000, 60_000)); + assertTrue(BackgroundSources.protect(false, false, policy, 2_000, 60_000)); + // A read that stops answering does not hold the phone awake for ever. + policy.observe(1, 1_000); + assertFalse(BackgroundSources.protect(true, true, policy, 1_000 + 120_000, 0)); + } + + @Test public void onlyThePhoneInUseStopsWhenIdleOrLost() { + ServerStatus idle = new ServerStatus(0, 0, Collections.emptyList()); + ServerStatus working = new ServerStatus(1, 0, Collections.emptyList()); + assertEquals(BackgroundSources.Next.STOP_IDLE, BackgroundSources.afterLocalRead(false, true, idle, false, false, 20_000, 10_000)); + assertEquals(BackgroundSources.Next.RESCHEDULE, BackgroundSources.afterLocalRead(false, true, idle, false, false, 5_000, 10_000)); + assertEquals(BackgroundSources.Next.RESCHEDULE, BackgroundSources.afterLocalRead(false, true, working, false, false, 20_000, 10_000)); + assertEquals(BackgroundSources.Next.STOP_LOST, BackgroundSources.afterLocalRead(false, true, null, true, false, 20_000, 10_000)); + // While paired, that computer's watch keeps the service: an idle or failing phone does not stop it. + assertEquals(BackgroundSources.Next.RESCHEDULE, BackgroundSources.afterLocalRead(true, true, idle, false, false, 20_000, 10_000)); + assertEquals(BackgroundSources.Next.RESCHEDULE, BackgroundSources.afterLocalRead(true, true, null, true, false, 20_000, 10_000)); + // A phone whose App Server is not running is not watched, and not started. + assertEquals(BackgroundSources.Next.END_WATCH, BackgroundSources.afterLocalRead(true, false, null, true, false, 20_000, 10_000)); + } + + @Test public void idlePhoneTasksAreReadSeldomWhilePaired() { + ServerStatus idle = new ServerStatus(0, 0, Collections.emptyList()); + ServerStatus working = new ServerStatus(1, 0, Collections.emptyList()); + assertEquals(30_000, BackgroundSources.delay(true, idle, false)); + assertEquals(5_000, BackgroundSources.delay(true, working, true)); + assertEquals(10_000, BackgroundSources.delay(true, null, false)); + assertEquals(15_000, BackgroundSources.delay(false, idle, true)); + assertEquals(5_000, BackgroundSources.delay(false, idle, false)); + } + + @Test public void aPhoneTaskTappedWhilePairedSwitchesToThePhoneFirst() { + assertEquals(BackgroundSources.Tap.SWITCH_THEN_OPEN, BackgroundSources.tap("this_device", true)); + assertEquals(BackgroundSources.Tap.OPEN, BackgroundSources.tap("this_device", false)); + assertEquals(BackgroundSources.Tap.OPEN, BackgroundSources.tap("paired", true)); + // A notification from before sources were carried opens where it is. + assertEquals(BackgroundSources.Tap.OPEN, BackgroundSources.tap(null, true)); + } + + private static AttentionNotifications.Attention event(String task, String id) { + return new AttentionNotifications.Attention(task, "Task", id, "completed", 5); + } +} diff --git a/apps/android/app/src/test/java/io/openaide/android/SetupPagePolicyTest.java b/apps/android/app/src/test/java/io/openaide/android/SetupPagePolicyTest.java index ee18b6f3..bce87908 100644 --- a/apps/android/app/src/test/java/io/openaide/android/SetupPagePolicyTest.java +++ b/apps/android/app/src/test/java/io/openaide/android/SetupPagePolicyTest.java @@ -15,6 +15,19 @@ public class SetupPagePolicyTest { assertFalse(SetupPagePolicy.isConnectionRoute(profile, address)); } + @Test public void onlySwitchingComputersWorksFromEveryPage() { + assertTrue(SetupPagePolicy.allowedOnEveryPage("use_computer")); + assertTrue(SetupPagePolicy.allowedOnEveryPage("local")); + assertTrue(SetupPagePolicy.allowedOnEveryPage("cancel_switch")); + for (String action : new String[]{"state", "forget_computer", "pair_setup", "install", "grant", "background", "diagnostics", ""}) + assertFalse(SetupPagePolicy.allowedOnEveryPage(action)); + } + + @Test public void onlySwitchCommandsAreAnsweredWhenTheSwitchEnds() { + for (String action : new String[]{"use_computer", "local", "cancel_switch", "forget_computer"}) assertTrue(SetupPagePolicy.switchesComputer(action)); + for (String action : new String[]{"state", "pair_setup", "update_check", ""}) assertFalse(SetupPagePolicy.switchesComputer(action)); + } + @Test public void servesOnlyBundledSetupAssets() { assertEquals("setup/index.html", SetupPagePolicy.asset("https://app.openaide.invalid/index.html")); assertEquals("setup/app.js", SetupPagePolicy.asset("https://app.openaide.invalid/app.js")); diff --git a/apps/android/app/src/test/kotlin/io/openaide/android/ComputerForgetTest.kt b/apps/android/app/src/test/kotlin/io/openaide/android/ComputerForgetTest.kt new file mode 100644 index 00000000..de0438a0 --- /dev/null +++ b/apps/android/app/src/test/kotlin/io/openaide/android/ComputerForgetTest.kt @@ -0,0 +1,115 @@ +package io.openaide.android + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ComputerForgetTest { + /** A phone paired with two computers and using one, with a real switch over fake steps. */ + private class Phone(var inUse: String? = "studio") { + val paired = mutableListOf("studio", "laptop") + val events: MutableList = java.util.Collections.synchronizedList(mutableListOf()) + val logged = mutableListOf>>() + val switchLogged = mutableListOf>>() + val answer = CompletableDeferred>() + val connected = CompletableDeferred() + val mainQueue = java.util.concurrent.LinkedBlockingQueue<() -> Unit>() + + val switch = ComputerSwitch( + CoroutineScope(SupervisorJob() + Dispatchers.Default), + { mainQueue.put(it) }, + { 0L }, + { _, outcome, _, fields -> synchronized(switchLogged) { switchLogged += outcome to fields } }, + object : ComputerSwitch.Steps { + override suspend fun connect(target: String?): String { + events += "connect $target" + connected.complete(Unit) + return answer.await().getOrThrow() + } + override fun apply(target: String?, connected: String) { + events += "apply $target" + inUse = target + } + override fun discard(connected: String) { events += "discard" } + override fun replacePage() { events += "replace page" } + }, + ) + + val pairings = object : ComputerForget.Pairings { + override fun inUse() = inUse + override fun forget(id: String) { + events += "forget $id" + paired -= id + } + } + + fun forget(id: String, operationId: String? = "forget-1") = ComputerForget.forget( + id, operationId, pairings, switch, { outcome, fields -> logged += outcome to fields }, + { events += "redraw" }, { outcome, problem -> events += "reply ${outcome ?: problem}" }, + ) + + fun awaitConnect() = runBlocking { withTimeout(5_000) { connected.await() } } + + fun runMain(count: Int) = repeat(count) { mainQueue.poll(5, java.util.concurrent.TimeUnit.SECONDS)!!.invoke() } + } + + @Test fun aComputerNotInUseIsForgottenAtOnce() { + val phone = Phone() + phone.forget("laptop") + assertEquals(listOf("forget laptop", "redraw", "reply forgotten"), phone.events) + assertEquals(listOf("studio"), phone.paired) + assertEquals("studio", phone.inUse) + assertEquals(listOf("other"), phone.logged.map { it.first }) + assertTrue(phone.switchLogged.isEmpty()) + } + + @Test fun theComputerInUseIsForgottenOnlyOnceThePhoneIsInUse() { + val phone = Phone() + phone.forget("studio") + phone.awaitConnect() + assertEquals(listOf("connect null"), phone.events) + assertEquals(listOf("studio", "laptop"), phone.paired) + + phone.answer.complete(Result.success("phone")) + phone.runMain(2) + assertEquals(listOf("connect null", "apply null", "forget studio", "reply switched", "replace page"), phone.events) + assertEquals(null, phone.inUse) + assertEquals(listOf("laptop"), phone.paired) + // The forget and the switch it rode on share one correlation id. + assertEquals(listOf("current" to mapOf("operation_id" to "forget-1")), phone.logged) + assertEquals(listOf("started", "switched"), phone.switchLogged.map { it.first }) + assertTrue(phone.switchLogged.all { it.second["operation_id"] == "forget-1" }) + } + + @Test fun aCancelKeepsThePairing() { + val phone = Phone() + phone.forget("studio") + phone.awaitConnect() + phone.switch.cancel() + assertEquals(listOf("connect null", "reply cancelled"), phone.events) + assertEquals(listOf("studio", "laptop"), phone.paired) + assertEquals("studio", phone.inUse) + assertEquals(listOf("kept"), phone.logged.map { it.first }) + } + + @Test fun aPhoneThatCannotStartChangesNothingAndSaysWhy() { + val phone = Phone() + phone.forget("studio", operationId = "unsafe id!") + phone.answer.complete(Result.failure(ComputerSwitch.Failed("local_unavailable", "Your local workspace could not start."))) + phone.runMain(1) + assertEquals(listOf("connect null", "reply Your local workspace could not start."), phone.events) + assertEquals(listOf("studio", "laptop"), phone.paired) + assertEquals("studio", phone.inUse) + assertTrue(phone.logged.isEmpty()) + // An id unsafe to log is replaced, and the switch still logs under one id. + val ids = phone.switchLogged.map { it.second.getValue("operation_id") }.toSet() + assertEquals(1, ids.size) + assertTrue(ids.single().startsWith("android-computer-switch-")) + } +} diff --git a/apps/android/app/src/test/kotlin/io/openaide/android/ComputerSwitchTest.kt b/apps/android/app/src/test/kotlin/io/openaide/android/ComputerSwitchTest.kt new file mode 100644 index 00000000..5849a829 --- /dev/null +++ b/apps/android/app/src/test/kotlin/io/openaide/android/ComputerSwitchTest.kt @@ -0,0 +1,150 @@ +package io.openaide.android + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ComputerSwitchTest { + /** The phone as the switch sees it: what is selected, what is live, and what happened in which order. */ + private class Phone { + var selected: String? = null + var live: String? = "connection-to-phone" + val events: MutableList = java.util.Collections.synchronizedList(mutableListOf()) + val answer = CompletableDeferred>() + val connected = CompletableDeferred() + val mainQueue = java.util.concurrent.LinkedBlockingQueue<() -> Unit>() + val logged = mutableListOf>>() + + val steps = object : ComputerSwitch.Steps { + override suspend fun connect(target: String?): String { + events += "connect $target" + connected.complete(Unit) + return answer.await().getOrThrow() + } + override fun apply(target: String?, connected: String) { + events += "apply $target" + selected = target + live = connected + } + override fun discard(connected: String) { events += "discard $connected" } + override fun replacePage() { events += "replace page" } + } + + val switch = ComputerSwitch( + CoroutineScope(SupervisorJob() + Dispatchers.Default), + { mainQueue.put(it) }, + { 0L }, + { _, outcome, _, fields -> synchronized(logged) { logged += outcome to fields } }, + steps, + ) + + /** A copy taken under the list's lock: the connect appends from another thread. */ + fun seen(): List = synchronized(events) { events.toList() } + + fun reply(): ComputerSwitch.Reply = ComputerSwitch.Reply { outcome, problem -> events += "reply ${outcome ?: problem}" } + + /** Runs what the switch posted to the main thread, waiting for the connect's result to arrive. */ + fun runMain(count: Int) = repeat(count) { + mainQueue.poll(5, java.util.concurrent.TimeUnit.SECONDS)!!.invoke() + } + } + + @Test fun aConnectedTargetIsSavedAnsweredAndOnlyThenReplacesThePage() { + val phone = Phone() + phone.switch.request(null, "studio", "computer-switch-1", phone.reply()) + assertTrue(phone.switch.switching) + phone.answer.complete(Result.success("connection-to-studio")) + phone.runMain(2) + + assertEquals(listOf("connect studio", "apply studio", "reply switched", "replace page"), phone.events) + assertEquals("studio", phone.selected) + assertEquals("connection-to-studio", phone.live) + assertFalse(phone.switch.switching) + assertEquals(listOf("started", "switched"), phone.logged.map { it.first }) + assertEquals( + mapOf("operation_id" to "computer-switch-1", "source_kind" to "this_device", "target_kind" to "paired"), + phone.logged.last().second, + ) + } + + @Test fun anUnreachableTargetChangesNothingAndSaysWhy() { + val phone = Phone() + phone.switch.request(null, "studio", null, phone.reply()) + phone.answer.complete(Result.failure(ComputerSwitch.Failed("unreachable", "Your computer is unreachable."))) + phone.runMain(1) + + assertEquals(listOf("connect studio", "reply Your computer is unreachable."), phone.events) + assertEquals(null, phone.selected) + assertEquals("connection-to-phone", phone.live) + assertEquals("unreachable", phone.logged.last().second["error_class"]) + assertTrue(phone.mainQueue.isEmpty()) + } + + @Test fun cancellingAnswersCancelledAndDropsWhatArrivesLater() { + val phone = Phone() + phone.switch.request("studio", null, null, phone.reply()) + runBlocking { withTimeout(5_000) { phone.connected.await() } } + assertTrue(phone.switch.cancel()) + assertFalse(phone.switch.cancel()) + + assertEquals(listOf("connect null", "reply cancelled"), phone.events) + assertEquals("connection-to-phone", phone.live) + assertEquals(listOf("started", "cancelled"), phone.logged.map { it.first }) + } + + @Test fun aTargetReachedAfterCancelIsGivenUp() { + val phone = Phone() + val gate = CompletableDeferred() + val switch = ComputerSwitch( + CoroutineScope(SupervisorJob() + Dispatchers.Default), + { phone.mainQueue.put(it) }, + { 0L }, + { _, _, _, _ -> }, + object : ComputerSwitch.Steps by phone.steps { + // A blocking start, as this phone's own App Server has, ignores the Cancel. + override suspend fun connect(target: String?): String { + phone.connected.complete(Unit) + while (!gate.isCompleted) Thread.sleep(5) + return "connection-to-studio" + } + }, + ) + switch.request(null, "studio", null, phone.reply()) + runBlocking { withTimeout(5_000) { phone.connected.await() } } + switch.cancel() + gate.complete(Unit) + phone.runMain(1) + + assertEquals(listOf("reply cancelled", "discard connection-to-studio"), phone.events) + assertEquals(null, phone.selected) + } + + @Test fun aSecondSwitchWaitsForTheFirst() { + val phone = Phone() + phone.switch.request(null, "studio", null, phone.reply()) + phone.switch.request(null, "laptop", null, phone.reply()) + assertEquals("reply Another computer is still connecting. Wait for it, or cancel it first.", phone.seen().last { it.startsWith("reply") }) + phone.switch.cancel() + } + + @Test fun theComputerInUseIsNotConnectedAgain() { + val phone = Phone() + phone.switch.request("studio", "studio", null, phone.reply()) + assertEquals(listOf("reply unchanged"), phone.events) + assertFalse(phone.switch.switching) + } + + @Test fun onlyAPlainlySafeOperationIdIsLogged() { + assertEquals("computer-switch-1_a", ComputerSwitch.operationId("computer-switch-1_a")) + listOf(null, "", "has space", "a/b", "x".repeat(81)).forEach { + assertTrue(ComputerSwitch.operationId(it).startsWith("android-computer-switch-")) + } + } +} diff --git a/apps/android/app/src/test/kotlin/io/openaide/android/ReconnectPolicyTest.kt b/apps/android/app/src/test/kotlin/io/openaide/android/ReconnectPolicyTest.kt index 62223dca..dea1f234 100644 --- a/apps/android/app/src/test/kotlin/io/openaide/android/ReconnectPolicyTest.kt +++ b/apps/android/app/src/test/kotlin/io/openaide/android/ReconnectPolicyTest.kt @@ -13,6 +13,11 @@ class ReconnectPolicyTest { assertTrue(ReconnectPolicy.staleAfterHidden(ReconnectPolicy.STALE_AFTER_HIDDEN_MS + 1)) } + @Test fun aFailedDialKeepsTheEndpointOfTheConnectionInUse() { + assertTrue(ReconnectPolicy.retireAfterFailedDial(carriesLiveConnection = false)) + assertFalse(ReconnectPolicy.retireAfterFailedDial(carriesLiveConnection = true)) + } + @Test fun attemptsGrowAndStayWithinTheBudget() { assertEquals(4_000L, ReconnectPolicy.attemptTimeoutMs(0, 0)) assertEquals(8_000L, ReconnectPolicy.attemptTimeoutMs(1, 4_000)) diff --git a/apps/desktop/frontend/desktopCommands.test.ts b/apps/desktop/frontend/desktopCommands.test.ts index 928c5521..9ac789de 100644 --- a/apps/desktop/frontend/desktopCommands.test.ts +++ b/apps/desktop/frontend/desktopCommands.test.ts @@ -10,6 +10,11 @@ describe("Desktop keyboard commands", () => { expect(desktopCommandForKeyboardEvent({ altKey: false, ctrlKey: true, key, metaKey: false, shiftKey: false })).toBe(command); }); + it("opens the computer switcher with Ctrl+Shift+S", () => { + expect(desktopCommandForKeyboardEvent({ altKey: false, ctrlKey: true, key: "S", metaKey: false, shiftKey: true })).toBe("switch-computer"); + expect(desktopCommandForKeyboardEvent({ altKey: false, ctrlKey: true, key: "s", metaKey: false, shiftKey: false })).toBeUndefined(); + }); + it("leaves modified and unrelated keys to the focused control", () => { expect(desktopCommandForKeyboardEvent({ altKey: false, ctrlKey: true, key: "n", metaKey: false, shiftKey: true })).toBeUndefined(); expect(desktopCommandForKeyboardEvent({ altKey: false, ctrlKey: false, key: "n", metaKey: false, shiftKey: false })).toBeUndefined(); diff --git a/apps/desktop/frontend/desktopCommands.ts b/apps/desktop/frontend/desktopCommands.ts index 3b133ed6..4e859fbd 100644 --- a/apps/desktop/frontend/desktopCommands.ts +++ b/apps/desktop/frontend/desktopCommands.ts @@ -1,4 +1,4 @@ -export type DesktopCommand = "check-for-updates" | "new-task" | "open-project" | "quit" | "settings"; +export type DesktopCommand = "check-for-updates" | "new-task" | "open-project" | "quit" | "settings" | "switch-computer"; export type DesktopSurfaceCommand = Exclude; type KeyboardCommandEvent = Pick< @@ -7,7 +7,8 @@ type KeyboardCommandEvent = Pick< >; export function desktopCommandForKeyboardEvent(event: KeyboardCommandEvent): DesktopSurfaceCommand | undefined { - if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return undefined; + if (!event.ctrlKey || event.metaKey || event.altKey) return undefined; + if (event.shiftKey) return event.key.toLowerCase() === "s" ? "switch-computer" : undefined; if (event.key.toLowerCase() === "n") return "new-task"; if (event.key.toLowerCase() === "o") return "open-project"; if (event.key === ",") return "settings"; diff --git a/apps/desktop/frontend/desktopComputers.test.ts b/apps/desktop/frontend/desktopComputers.test.ts new file mode 100644 index 00000000..5fa593aa --- /dev/null +++ b/apps/desktop/frontend/desktopComputers.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createDesktopComputers } from "./desktopComputers"; + +const studio = { id: "a".repeat(64), name: "Studio", current: false }; + +afterEach(() => vi.restoreAllMocks()); + +function invoker(answers: Record Promise>) { + return vi.fn((command: string, _args?: Record) => answers[command]?.() ?? Promise.resolve(undefined)) as never; +} + +describe("Desktop computers", () => { + it("publishes the paired computers once the shell has read them", async () => { + const invoke = invoker({ desktop_remote_snapshot: async () => [studio] }); + const computers = createDesktopComputers(invoke, async () => undefined); + const changed = vi.fn(); + computers.subscribe(changed); + expect(computers.snapshot()).toBeUndefined(); + await vi.waitFor(() => expect(changed).toHaveBeenCalled()); + expect(computers.snapshot()).toEqual({ device: "computer", computers: [studio] }); + expect(computers.snapshot()).toBe(computers.snapshot()); + }); + + it("says when the list could not be read and reads it again for the next viewer", async () => { + vi.spyOn(console, "warn").mockImplementation(() => undefined); + let fail = true; + const invoke = invoker({ desktop_remote_snapshot: async () => { if (fail) throw new Error("storage"); return [studio]; } }); + const computers = createDesktopComputers(invoke, async () => undefined); + await vi.waitFor(() => expect(computers.snapshot()?.error).toBe("OpenAIDE could not read its paired computers. Try again.")); + expect(computers.snapshot()?.computers).toEqual([]); + fail = false; + const unsubscribe = computers.subscribe(() => undefined); + await vi.waitFor(() => expect(computers.snapshot()).toEqual({ device: "computer", computers: [studio] })); + unsubscribe(); + }); + + it("reloads the window only once the native side switched", async () => { + const outcomes = ["switched", "alreadyInUse", "cancelled"]; + const invoke = invoker({ + desktop_remote_snapshot: async () => [studio], + desktop_remote_use: async () => outcomes.shift(), + }) as ReturnType; + const reload = vi.fn(async () => undefined); + const computers = createDesktopComputers(invoke as never, reload); + expect(await computers.use(studio.id, "computer-switch-1")).toBe("switched"); + expect(reload).toHaveBeenCalledOnce(); + expect(await computers.use(undefined)).toBe("unchanged"); + expect(await computers.use(studio.id)).toBe("cancelled"); + expect(reload).toHaveBeenCalledOnce(); + expect(invoke.mock.calls.filter(([command]) => command === "desktop_remote_use")).toEqual([ + ["desktop_remote_use", { id: studio.id, operationId: "computer-switch-1" }], + ["desktop_remote_use", { id: null, operationId: null }], + ["desktop_remote_use", { id: studio.id, operationId: null }], + ]); + }); + + it("leaves a refused switch to the caller without reloading", async () => { + const invoke = invoker({ desktop_remote_use: async () => { throw "Studio is unreachable."; } }); + const reload = vi.fn(async () => undefined); + const computers = createDesktopComputers(invoke, reload); + await expect(computers.use(studio.id)).rejects.toBe("Studio is unreachable."); + expect(reload).not.toHaveBeenCalled(); + }); + + it("cancels a switch through the shell", async () => { + const invoke = invoker({ desktop_remote_cancel: async () => true }) as ReturnType; + await createDesktopComputers(invoke as never, async () => undefined).cancel!(); + expect(invoke).toHaveBeenCalledWith("desktop_remote_cancel"); + }); + + it("rereads the list after forgetting or pairing unless the window moved", async () => { + const answers = { desktop_remote_forget: "alreadyInUse", desktop_remote_pair: "switched" }; + const invoke = invoker({ + desktop_remote_snapshot: async () => [studio], + desktop_remote_forget: async () => answers.desktop_remote_forget, + desktop_remote_pair: async () => answers.desktop_remote_pair, + }) as ReturnType; + const reload = vi.fn(async () => undefined); + const computers = createDesktopComputers(invoke as never, reload); + await computers.forget(studio.id); + await computers.pair!("OAI1 CODE"); + expect(invoke.mock.calls.filter(([command]) => command !== "desktop_remote_snapshot")).toEqual([ + ["desktop_remote_forget", { id: studio.id, operationId: null }], + ["desktop_remote_pair", { code: "OAI1 CODE", operationId: null }], + ]); + expect(reload).toHaveBeenCalledOnce(); + expect(invoke.mock.calls.filter(([command]) => command === "desktop_remote_snapshot")).toHaveLength(2); + }); +}); diff --git a/apps/desktop/frontend/desktopComputers.ts b/apps/desktop/frontend/desktopComputers.ts new file mode 100644 index 00000000..69567807 --- /dev/null +++ b/apps/desktop/frontend/desktopComputers.ts @@ -0,0 +1,65 @@ +import type { invoke as tauriInvoke } from "@tauri-apps/api/core"; +import type { ComputerSwitchResult, ComputersCapability, ComputersSnapshot, ShellComputer } from "../../../packages/frontend/src/services/computers"; + +/** What the native side reports for a switch it was asked for. */ +type NativeSwitchOutcome = "switched" | "alreadyInUse" | "cancelled"; + +/** + * The Desktop's paired computers (ADR-0063), kept by the native side in its + * bootstrap preferences. The native side connects the chosen computer first and + * keeps this computer's App Server running (ADR-0070); once it switched, the + * window leaves its App Server and reloads onto the new one. + */ +export function createDesktopComputers( + invoke: typeof tauriInvoke, + /** Leaves the App Server in use and reloads the window onto the one now chosen. */ + reload: () => Promise, +): ComputersCapability { + let snapshot: ComputersSnapshot | undefined; + let reading: Promise | undefined; + const listeners = new Set<() => void>(); + const publish = (next: ComputersSnapshot) => { + snapshot = next; + listeners.forEach((listener) => listener()); + }; + const read = () => reading ??= (async () => { + const startedAt = performance.now(); + try { + publish({ device: "computer", computers: await invoke("desktop_remote_snapshot") }); + } catch { + console.warn(`desktop_computers_read_completed outcome=failure duration_ms=${Math.round(performance.now() - startedAt)}`); + publish({ device: "computer", computers: snapshot?.computers ?? [], error: "OpenAIDE could not read its paired computers. Try again." }); + } finally { + reading = undefined; + } + })(); + void read(); + return { + snapshot: () => snapshot, + subscribe(listener) { + listeners.add(listener); + // A failed read is retried when something new starts watching, such as Settings opening. + if (snapshot?.error) void read(); + return () => listeners.delete(listener); + }, + async use(id, operationId) { + return settle(await invoke("desktop_remote_use", { id: id ?? null, operationId: operationId ?? null })); + }, + cancel: async () => { + await invoke("desktop_remote_cancel"); + }, + async forget(id) { + // Forgetting the computer in use moves the window to this computer first. + if (await settle(await invoke("desktop_remote_forget", { id, operationId: null })) !== "switched") await read(); + }, + async pair(code) { + if (await settle(await invoke("desktop_remote_pair", { code, operationId: null })) !== "switched") await read(); + }, + }; + + async function settle(outcome: NativeSwitchOutcome): Promise { + if (outcome === "alreadyInUse") return "unchanged"; + if (outcome === "switched") await reload(); + return outcome; + } +} diff --git a/apps/desktop/frontend/desktopShell.ts b/apps/desktop/frontend/desktopShell.ts index 412a7404..7cd9cab8 100644 --- a/apps/desktop/frontend/desktopShell.ts +++ b/apps/desktop/frontend/desktopShell.ts @@ -12,14 +12,16 @@ import { } from "@tauri-apps/plugin-notification"; import { openUrl } from "@tauri-apps/plugin-opener"; import { CLIENT_DETACH, type AppServerSession } from "@openaide/app-server-client"; -import type { HostToWebviewMessage, SettingsIntent, SettingsTabId } from "@openaide/app-shell-contracts"; -import type { FrontendShell, RemoteComputer } from "../../../packages/frontend/src/services/frontendShell"; +import type { HostToWebviewMessage, SettingsIntent, SettingsTabId, TaskSummary } from "@openaide/app-shell-contracts"; +import type { FrontendShell } from "../../../packages/frontend/src/services/frontendShell"; +import { computerSwitch } from "../../../packages/frontend/src/services/computerSwitch"; import { createShellAppearance } from "../../../packages/frontend/src/services/shellAppearance"; import type { PostHostMessage } from "../../../packages/frontend/src/state/postHostMessage"; import type { WebviewBootstrap } from "../../../packages/frontend/src/state/surfaceTypes"; import { quitDesktop } from "./desktopQuitLifecycle"; import { createDesktopProjectPicker } from "./desktopProjectPicker"; import { createDesktopServerRecovery } from "./desktopServerRecovery"; +import { createDesktopComputers } from "./desktopComputers"; import { createDesktopUpdates } from "./desktopUpdates"; import type { DesktopBootstrap, LocalHttpConnection } from "./desktopBootstrap"; import { desktopCommandForKeyboardEvent, type DesktopCommand, type DesktopSurfaceCommand } from "./desktopCommands"; @@ -27,7 +29,13 @@ import { createDesktopSupportExports } from "./desktopSupportExports"; import { createDesktopFileViewerContent, desktopPdfRendererDocumentUrl } from "./desktopFileViewer"; import { createDesktopFileUploads } from "./desktopFileUploads"; import { createDesktopSecretMessageHandler } from "./desktopSecrets"; -import { createDesktopTaskNotifications } from "./desktopTaskNotifications"; +import { createDesktopTaskNotifications, type DesktopNotificationHost } from "./desktopTaskNotifications"; +import { + takeThisComputerTask, + THIS_COMPUTER_ATTENTION_EVENT, + THIS_COMPUTER_ATTENTION_SNAPSHOT, + watchThisComputerAttention, +} from "./desktopThisComputerAttention"; type DesktopRoute = | { surface: "nativeSession"; agentId: string; nativeSessionId: string; projectId?: string } @@ -44,14 +52,13 @@ export function createDesktopShell( document.body.dataset.platform = host.platform; const nativeWindow = getCurrentWindow(); const nativeWebview = getCurrentWebview(); - // Desktop owns the App Server process, so return-to-app restores it here. The + // Desktop always runs this computer's App Server, even while the window uses a + // paired computer's (ADR-0070), so return-to-app restores it here. The // subscription lives as long as the WebView. Native focus is the reliable // machine-resume signal on macOS, and re-broadcasting it as `openaide:resume` // also wakes the shared transport and restarts suspended animations. - // With a paired computer in use there is no process here to restore; the - // session's own retries reopen the connection. const remote = Boolean(host.remoteComputer); - if (!remote) createDesktopServerRecovery({ invoke }); + createDesktopServerRecovery({ invoke }); void nativeWindow.onFocusChanged(({ payload: focused }) => { if (focused) window.dispatchEvent(new Event("openaide:resume")); }); @@ -112,7 +119,9 @@ export function createDesktopShell( reload: () => window.location.reload(), session, }); - let route: DesktopRoute = { surface: "task" }; + // A click on this computer's notification switched here to open its Task. + const arrivedTaskId = remote ? undefined : takeThisComputerTask(window.sessionStorage, Date.now()); + let route: DesktopRoute = arrivedTaskId ? { surface: "task", taskId: arrivedTaskId } : { surface: "task" }; const routeListeners = new Set<(bootstrap: WebviewBootstrap) => void>(); const messageListeners = new Set<(message: HostToWebviewMessage) => void>(); const handleSecretMessage = createDesktopSecretMessageHandler(invoke); @@ -237,6 +246,48 @@ export function createDesktopShell( } }; + const notificationHost: DesktopNotificationHost = { + storage: window.localStorage, + isPermissionGranted, + requestPermission, + sendNotification, + removeActive, + onAction, + initiallyFocused: () => document.hasFocus(), + isFocused: () => nativeWindow.isFocused(), + onFocusChanged: (listener) => nativeWindow.onFocusChanged(({ payload }) => listener(payload)), + focusedTaskId: () => route.surface === "task" ? route.taskId : undefined, + async focusWindow() { + await nativeWindow.unminimize(); + await nativeWindow.show(); + await nativeWindow.setFocus(); + }, + openTask: (taskId) => navigate({ surface: "task", taskId }), + now: () => Date.now(), + record: (event, fields) => { + void invoke("record_desktop_webview_log", { event, fields }).catch(() => undefined); + }, + }; + const computers = createDesktopComputers(invoke, () => leaveForAnotherComputer(session)); + if (remote) { + watchThisComputerAttention({ + notifications: (storage, openTask) => createDesktopTaskNotifications({ + ...notificationHost, + storage, + openTask, + // None of this computer's Tasks is in the window, so none is in sight. + focusedTaskId: () => undefined, + source: "thisComputer", + idBase: THIS_COMPUTER_NOTIFICATION_ID_BASE, + }), + storage: window.localStorage, + session: window.sessionStorage, + snapshot: () => invoke(THIS_COMPUTER_ATTENTION_SNAPSHOT), + listen: (handler) => listen<{ tasks: TaskSummary[] }>(THIS_COMPUTER_ATTENTION_EVENT, ({ payload }) => handler(payload.tasks)), + switcher: computerSwitch(computers), + now: () => Date.now(), + }); + } return { appearance, backendConnection: () => session, @@ -255,13 +306,7 @@ export function createDesktopShell( }), select: (environment) => invoke("set_desktop_runtime_environment", { environment }), } : undefined, - remoteComputers: { - current: host.remoteComputer, - list: () => invoke("desktop_remote_snapshot"), - pair: (code) => invoke("desktop_remote_pair", { code }), - use: (id) => invoke("desktop_remote_use", { id: id ?? null }), - forget: (id) => invoke("desktop_remote_forget", { id }), - }, + computers, desktopUpdates, desktopCommands: { subscribe(listener) { @@ -328,31 +373,44 @@ export function createDesktopShell( fileViewer: true, fileViewerContent: createDesktopFileViewerContent(connection, host.clientInstanceId), pdfRendererDocumentUrl: desktopPdfRendererDocumentUrl(host.platform), - taskNotifications: createDesktopTaskNotifications({ - storage: window.localStorage, - isPermissionGranted, - requestPermission, - sendNotification, - removeActive, - onAction, - initiallyFocused: () => document.hasFocus(), - isFocused: () => nativeWindow.isFocused(), - onFocusChanged: (listener) => nativeWindow.onFocusChanged(({ payload }) => listener(payload)), - focusedTaskId: () => route.surface === "task" ? route.taskId : undefined, - async focusWindow() { - await nativeWindow.unminimize(); - await nativeWindow.show(); - await nativeWindow.setFocus(); - }, - openTask: (taskId) => navigate({ surface: "task", taskId }), - now: () => Date.now(), - record: (event, fields) => { - void invoke("record_desktop_webview_log", { event, fields }).catch(() => undefined); - }, - }), + taskNotifications: createDesktopTaskNotifications(notificationHost), }; } +/** Clear of the window manager's ids, which count up from 1 for the life of the page. */ +const THIS_COMPUTER_NOTIFICATION_ID_BASE = 1_000_000; +const SWITCH_DETACH_TIMEOUT_MS = 1_000; +const SWITCH_DETACH_ATTEMPTS = 2; + +/** + * Detaches from the App Server the window leaves, so this computer's App Server + * does not keep a client for a window that moved on (a lingering one would also + * refuse a Desktop Update until it expires), then reloads onto the one now in + * use. A detach that did not answer is tried once more. A paired computer's link + * is already closed, so its detach fails at once and changes nothing. + */ +async function leaveForAnotherComputer(session: AppServerSession) { + const startedAt = performance.now(); + let outcome = "detachFailed"; + let attempt = 0; + while (attempt < SWITCH_DETACH_ATTEMPTS && outcome !== "detached") { + attempt += 1; + outcome = await detachWithin(session, SWITCH_DETACH_TIMEOUT_MS); + } + console.info(`desktop_computer_switch_window_reload outcome=${outcome} attempt_count=${attempt} duration_ms=${Math.round(performance.now() - startedAt)}`); + window.location.reload(); +} + +async function detachWithin(session: AppServerSession, timeoutMs: number) { + let timer: ReturnType | undefined; + const outcome = await Promise.race([ + session.request(CLIENT_DETACH, {}).then(() => "detached", () => "detachFailed"), + new Promise((resolve) => { timer = setTimeout(() => resolve("detachTimeout"), timeoutMs); }), + ]); + clearTimeout(timer); + return outcome; +} + function nextAnimationFrame() { return new Promise((resolve) => requestAnimationFrame(() => resolve())); } diff --git a/apps/desktop/frontend/desktopTaskNotifications.test.ts b/apps/desktop/frontend/desktopTaskNotifications.test.ts index be88bfd6..2dbd3061 100644 --- a/apps/desktop/frontend/desktopTaskNotifications.test.ts +++ b/apps/desktop/frontend/desktopTaskNotifications.test.ts @@ -110,9 +110,44 @@ describe("Desktop Task notifications", () => { expect(host.openTask).toHaveBeenCalledWith("task-9"); expect(host.focusWindow).toHaveBeenCalledTimes(1); }); + + it("keeps a second source's notifications and clicks apart from the window's", async () => { + const host = fakeHost({ source: "thisComputer", idBase: 1_000_000 }); + const manager = createDesktopTaskNotifications(host); + await settled(); + manager.reconcile("root-1", []); + + manager.reconcile("root-1", [task("attention-1")]); + host.tap({ id: 7, title: "Task finished", extra: { taskId: "task-9" } }); + await settled(); + + expect(host.sent).toEqual([expect.objectContaining({ + id: 1_000_000, + extra: { taskId: "task-1", source: "thisComputer" }, + })]); + expect(host.openTask).not.toHaveBeenCalled(); + host.tap(host.sent[0]); + expect(host.openTask).toHaveBeenCalledWith("task-1"); + }); + + it("leaves a second source's clicks to that source", async () => { + const host = fakeHost(); + createDesktopTaskNotifications(host); + await settled(); + + host.tap({ id: 1_000_000, title: "Task finished", extra: { taskId: "task-9", source: "thisComputer" } }); + await settled(); + + expect(host.openTask).not.toHaveBeenCalled(); + }); }); -function fakeHost({ focused = false, focusedTaskId }: { focused?: boolean; focusedTaskId?: string } = {}) { +function fakeHost({ focused = false, focusedTaskId, source, idBase }: { + focused?: boolean; + focusedTaskId?: string; + source?: string; + idBase?: number; +} = {}) { const values = new Map(); let actionListener: ((event: ActionPerformed) => void) | undefined; const now = Date.parse("2026-10-16T09:00:00.000Z"); @@ -129,6 +164,8 @@ function fakeHost({ focused = false, focusedTaskId }: { focused?: boolean; focus } = { sent, recorded, + source, + idBase, storage: { getItem: (key) => values.get(key) ?? null, setItem: (key, value) => { values.set(key, value); }, diff --git a/apps/desktop/frontend/desktopTaskNotifications.ts b/apps/desktop/frontend/desktopTaskNotifications.ts index aad2948e..48d9b105 100644 --- a/apps/desktop/frontend/desktopTaskNotifications.ts +++ b/apps/desktop/frontend/desktopTaskNotifications.ts @@ -26,6 +26,12 @@ export type DesktopNotificationHost = { now(): number; /** Persists one event in the Desktop log, which outlives the renderer console. */ record(event: string, fields: DesktopNotificationLogFields): void; + /** + * Names a second manager's notifications, which start from `idBase`. Every + * manager hears every click, so each acts only on its own source's. + */ + source?: string; + idBase?: number; }; /** @@ -51,7 +57,7 @@ export function createDesktopTaskNotifications(host: DesktopNotificationHost): W const clickHandlers = new Map void>(); let focused = host.initiallyFocused(); let permission: NotificationPermission = "default"; - let nextId = 1; + let nextId = host.idBase ?? 1; const changed = () => { for (const listener of focusListeners) listener(); }; // Desktop permission needs no prompt, so a default-on choice obtains it right away. @@ -69,7 +75,7 @@ export function createDesktopTaskNotifications(host: DesktopNotificationHost): W void host.onFocusChanged((value) => { focused = value; changed(); }); // Without a listener the plugin reports no clicks, so it must exist before the first notification. void host.onAction((event) => { - if (event.actionId !== "tap") return; + if (event.actionId !== "tap" || event.notification?.extra?.source !== host.source) return; const id = event.notification?.id; const handler = id === undefined ? undefined : clickHandlers.get(id); diagnose("info", "desktop_notification_activated", { notification_id: id ?? "unknown", handler: handler ? "live" : "missing" }); @@ -104,8 +110,9 @@ export function createDesktopTaskNotifications(host: DesktopNotificationHost): W const id = nextId++; const taskId = options.tag?.replace(/^openaide-task-/, ""); try { - host.sendNotification({ id, title, body: options.body, extra: taskId ? { taskId } : undefined }); - diagnose("info", "desktop_notification_shown", { notification_id: id }); + const extra = { ...(taskId ? { taskId } : {}), ...(host.source ? { source: host.source } : {}) }; + host.sendNotification({ id, title, body: options.body, extra: Object.keys(extra).length ? extra : undefined }); + diagnose("info", "desktop_notification_shown", { notification_id: id, ...(host.source ? { source: host.source } : {}) }); } catch { diagnose("warn", "desktop_notification_failed", { notification_id: id, error_kind: "plugin" }); return { close: () => undefined }; diff --git a/apps/desktop/frontend/desktopThisComputerAttention.test.ts b/apps/desktop/frontend/desktopThisComputerAttention.test.ts new file mode 100644 index 00000000..cce0c6e0 --- /dev/null +++ b/apps/desktop/frontend/desktopThisComputerAttention.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, vi } from "vitest"; +import type { TaskSummary } from "@openaide/app-shell-contracts"; +import type { ComputerSwitch, ComputerSwitchState } from "../../../packages/frontend/src/services/computerSwitch"; +import type { WebTaskNotificationManager } from "../../../packages/frontend/src/shells/webTaskNotifications"; +import { takeThisComputerTask, watchThisComputerAttention, type ThisComputerAttentionHost } from "./desktopThisComputerAttention"; + +describe("this computer's attention while a paired computer is in use", () => { + it("starts from the watcher's latest list and follows its changes", async () => { + const host = fakeHost({ snapshot: [task("t1")] }); + watchThisComputerAttention(host); + await settled(); + + host.emit([task("t1"), task("t2")]); + + expect(host.reconciled).toEqual([["thisComputer", ["t1"]], ["thisComputer", ["t1", "t2"]]]); + }); + + it("ignores a snapshot older than an event that already came", async () => { + let answer: (tasks: TaskSummary[]) => void = () => undefined; + const host = fakeHost({ snapshot: new Promise((resolve) => { answer = resolve; }) }); + watchThisComputerAttention(host); + await settled(); + host.emit([task("t2")]); + + answer([task("t1")]); + await settled(); + + expect(host.reconciled).toEqual([["thisComputer", ["t2"]]]); + }); + + it("keeps its receipts apart from the window's but shares the on/off choice", () => { + const host = fakeHost({ snapshot: null }); + watchThisComputerAttention(host); + + host.managerStorage?.setItem("openaide.desktopNotifications.handled", "[\"e1\"]"); + host.values.set("openaide.desktopNotifications.enabled", "false"); + + expect(host.values.get("openaide.desktopNotifications.handled.thisComputer")).toBe("[\"e1\"]"); + expect(host.values.has("openaide.desktopNotifications.handled")).toBe(false); + expect(host.managerStorage?.getItem("openaide.desktopNotifications.enabled")).toBe("false"); + }); + + it("switches back to this computer on a click and opens the Task after the reload", () => { + const host = fakeHost({ snapshot: null }); + watchThisComputerAttention(host); + + host.click("t7"); + + expect(host.switcher.request).toHaveBeenCalledWith(undefined); + expect(takeThisComputerTask(host.session, host.now())).toBe("t7"); + expect(takeThisComputerTask(host.session, host.now())).toBeUndefined(); + }); + + it("forgets the click when the switch did not happen", () => { + const host = fakeHost({ snapshot: null }); + watchThisComputerAttention(host); + host.click("t7"); + + host.switcher.publish({ confirming: "", cancellable: false }); + expect(host.sessionValues.size).toBe(1); + host.switcher.publish({ cancellable: false }); + + expect(takeThisComputerTask(host.session, host.now())).toBeUndefined(); + }); + + it("does not open a Task a long-past click asked for", () => { + const host = fakeHost({ snapshot: null }); + watchThisComputerAttention(host); + host.click("t7"); + + // timing: data — a click's age compared as a value, never waited on. + expect(takeThisComputerTask(host.session, host.now() + 11 * 60_000)).toBeUndefined(); + }); +}); + +function fakeHost({ snapshot }: { snapshot: TaskSummary[] | null | Promise }) { + const values = new Map(); + const sessionValues = new Map(); + const reconciled: Array<[string, string[]]> = []; + let emitter: (tasks: TaskSummary[]) => void = () => undefined; + let clicker: (taskId: string) => void = () => undefined; + let state: ComputerSwitchState = { cancellable: false }; + const listeners = new Set<() => void>(); + const switcher = { + snapshot: () => state, + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + request: vi.fn(async () => { state = { pending: "", cancellable: false }; }), + confirm: async () => undefined, + decline: () => undefined, + cancel: () => undefined, + publish(next: ComputerSwitchState) { + state = next; + for (const listener of [...listeners]) listener(); + }, + } satisfies ComputerSwitch & { publish(next: ComputerSwitchState): void }; + const storageOf = (map: Map) => ({ + getItem: (key: string) => map.get(key) ?? null, + setItem: (key: string, value: string) => { map.set(key, value); }, + removeItem: (key: string) => { map.delete(key); }, + }); + const host: ThisComputerAttentionHost & { + values: typeof values; + sessionValues: typeof sessionValues; + reconciled: typeof reconciled; + managerStorage?: ThisComputerAttentionHost["storage"]; + switcher: typeof switcher; + emit(tasks: TaskSummary[]): void; + click(taskId: string): void; + } = { + values, + sessionValues, + reconciled, + notifications(storage, openTask) { + host.managerStorage = storage; + clicker = openTask; + return { reconcile: (root: string, tasks: TaskSummary[]) => { reconciled.push([root, tasks.map((item) => item.task_id)]); } } as unknown as WebTaskNotificationManager; + }, + storage: storageOf(values), + session: storageOf(sessionValues), + snapshot: () => Promise.resolve(snapshot), + listen: async (handler) => { emitter = handler; }, + switcher, + now: () => Date.parse("2026-10-23T09:00:00.000Z"), + emit: (tasks) => emitter(tasks), + click: (taskId) => clicker(taskId), + }; + return host; +} + +function task(taskId: string): TaskSummary { + return { task_id: taskId, title: "Fix the build" } as TaskSummary; +} + +async function settled() { + for (let index = 0; index < 5; index += 1) await Promise.resolve(); +} diff --git a/apps/desktop/frontend/desktopThisComputerAttention.ts b/apps/desktop/frontend/desktopThisComputerAttention.ts new file mode 100644 index 00000000..4a160cb4 --- /dev/null +++ b/apps/desktop/frontend/desktopThisComputerAttention.ts @@ -0,0 +1,98 @@ +import type { TaskSummary } from "@openaide/app-shell-contracts"; +import type { ComputerSwitch } from "../../../packages/frontend/src/services/computerSwitch"; +import { + TASK_NOTIFICATION_RECEIPTS_KEY, + type WebTaskNotificationEnvironment, + type WebTaskNotificationManager, +} from "../../../packages/frontend/src/shells/webTaskNotifications"; + +type Storage = WebTaskNotificationEnvironment["storage"]; +type SessionStorage = Pick; + +/** The native watcher's event and its latest list (`desktop_local_attention.rs`). */ +export const THIS_COMPUTER_ATTENTION_EVENT = "desktop-local-attention"; +export const THIS_COMPUTER_ATTENTION_SNAPSHOT = "desktop_local_attention_snapshot"; +/** The Task a notification click asked for, carried across the switch's page reload. */ +const OPEN_TASK_KEY = "openaide.desktopThisComputerTask"; +/** A click whose switch never finished must not open a Task on some later switch. */ +const OPEN_TASK_TTL_MS = 10 * 60_000; +const STATE_ROOT = "thisComputer"; + +export type ThisComputerAttentionHost = { + /** Creates the manager for this computer's Tasks on `storage`; its clicks call `openTask`. */ + notifications(storage: Storage, openTask: (taskId: string) => void): WebTaskNotificationManager; + storage: Storage; + session: SessionStorage; + snapshot(): Promise; + listen(handler: (tasks: TaskSummary[]) => void): Promise; + switcher: ComputerSwitch; + now(): number; +}; + +/** + * Notifies this computer's Tasks while the window uses a paired computer + * (ADR-0070 Attention). The native watcher supplies the attention list; a + * second notification manager applies the usual rules to it with its own + * receipts, sharing the user's on/off choice with the window's manager. A click + * switches back to this computer through the shared switch, so unsent input is + * still asked about, and the reloaded page opens the Task. + */ +export function watchThisComputerAttention(host: ThisComputerAttentionHost): WebTaskNotificationManager { + const manager = host.notifications(receiptsApart(host.storage), (taskId) => { + try { + host.session.setItem(OPEN_TASK_KEY, JSON.stringify({ taskId, at: host.now() })); + } catch { + console.warn("desktop_this_computer_attention_open_failed error_kind=storage"); + } + console.info("desktop_this_computer_attention_open outcome=switching"); + // The page stays only if the switch did not happen; then the click is spent. + const stop = host.switcher.subscribe(() => { + const state = host.switcher.snapshot(); + if (state.pending !== undefined || state.confirming !== undefined) return; + stop(); + host.session.removeItem(OPEN_TASK_KEY); + console.info("desktop_this_computer_attention_open outcome=stayed"); + }); + void host.switcher.request(undefined); + }); + let heard = false; + void host.listen((tasks) => { + heard = true; + manager.reconcile(STATE_ROOT, tasks); + }).catch(() => console.warn("desktop_this_computer_attention_listen_failed error_kind=event")); + // A reloaded window starts from the watcher's latest list, unless an event already came. + void host.snapshot().then( + (tasks) => { + console.info(`desktop_this_computer_attention_snapshot outcome=${tasks ? "found" : "empty"} tasks=${tasks?.length ?? 0}`); + if (tasks && !heard) manager.reconcile(STATE_ROOT, tasks); + }, + () => console.warn("desktop_this_computer_attention_snapshot outcome=failure error_kind=command"), + ); + return manager; +} + +/** The Task a notification click asked to open on this computer, taken once. */ +export function takeThisComputerTask(session: SessionStorage, now: number): string | undefined { + let stored: { taskId?: unknown; at?: unknown } | undefined; + try { + const value = session.getItem(OPEN_TASK_KEY); + session.removeItem(OPEN_TASK_KEY); + stored = value ? JSON.parse(value) as typeof stored : undefined; + } catch { + return undefined; + } + if (typeof stored?.taskId !== "string" || typeof stored.at !== "number") return undefined; + const fresh = now - stored.at <= OPEN_TASK_TTL_MS; + console.info(`desktop_this_computer_attention_open outcome=${fresh ? "arrived" : "expired"}`); + return fresh ? stored.taskId : undefined; +} + +/** Shares every setting with the window's manager but keeps this computer's receipts apart. */ +function receiptsApart(storage: Storage): Storage { + const own = (key: string) => key === TASK_NOTIFICATION_RECEIPTS_KEY ? `${key}.thisComputer` : key; + return { + getItem: (key) => storage.getItem(own(key)), + setItem: (key, value) => storage.setItem(own(key), value), + removeItem: (key) => storage.removeItem(own(key)), + }; +} diff --git a/apps/desktop/frontend/desktopUpdates.test.ts b/apps/desktop/frontend/desktopUpdates.test.ts index a044e53c..93960bdc 100644 --- a/apps/desktop/frontend/desktopUpdates.test.ts +++ b/apps/desktop/frontend/desktopUpdates.test.ts @@ -70,9 +70,11 @@ describe("Desktop update shell adapter", () => { ); }); - it("updates without asking a paired computer's App Server to shut down", async () => { + it("stops this computer's App Server, not a paired computer's, while the paired one is in use", async () => { const session = fakeSession(); - const invoke = vi.fn(async () => snapshot(2, "applying")); + const invoke = vi.fn(async (command: string, _args?: unknown) => ( + command === "desktop_update_prepare_local" ? { kind: "ready" } : snapshot(2, "applying") + ) as DesktopUpdateSnapshot); const updates = createDesktopUpdates({ invoke, listen: vi.fn(async () => () => undefined), @@ -82,11 +84,57 @@ describe("Desktop update shell adapter", () => { session, }); - await expect(updates.restartAndUpdate()).resolves.toBe("started"); + await expect(updates.restartAndUpdate({ stopActiveWork: true })).resolves.toBe("started"); expect(session.request.mock.calls.map(([method]) => method)).toEqual(["client/detach"]); expect(session.close).toHaveBeenCalledOnce(); - expect(invoke).toHaveBeenLastCalledWith("desktop_install_update"); + expect(invoke.mock.calls.slice(1)).toEqual([ + ["desktop_update_prepare_local", { attemptId: expect.any(String), stopActiveWork: true }], + ["desktop_update_finish_local", { attemptId: expect.any(String), commit: true }], + ["desktop_install_update"], + ]); + }); + + it("leaves everything running when this computer's App Server has work to confirm", async () => { + const session = fakeSession(); + const invoke = vi.fn(async (command: string, _args?: unknown) => ( + command === "desktop_update_prepare_local" ? { kind: "blocked", reason: "activeWork" } : snapshot(2, "idle") + ) as DesktopUpdateSnapshot); + const updates = createDesktopUpdates({ + invoke, + listen: vi.fn(async () => () => undefined), + openReleaseNotes: vi.fn(), + ownsAppServer: false, + reload: vi.fn(), + session, + }); + + await expect(updates.restartAndUpdate()).resolves.toBe("activeWork"); + + expect(session.request).not.toHaveBeenCalled(); + expect(invoke.mock.calls.map(([command]) => command)).not.toContain("desktop_update_finish_local"); + }); + + it("aborts this computer's App Server shutdown when it could not be committed", async () => { + const session = fakeSession(); + const invoke = vi.fn(async (command: string, args?: unknown) => { + if (command === "desktop_update_prepare_local") return { kind: "ready" } as never; + if (command === "desktop_update_finish_local" && (args as { commit?: boolean }).commit) throw new Error("unreachable"); + return snapshot(2, "idle"); + }); + const updates = createDesktopUpdates({ + invoke, + listen: vi.fn(async () => () => undefined), + openReleaseNotes: vi.fn(), + ownsAppServer: false, + reload: vi.fn(), + session, + }); + + await expect(updates.restartAndUpdate()).rejects.toThrow("unreachable"); + + expect(invoke).toHaveBeenLastCalledWith("desktop_update_finish_local", { attemptId: expect.any(String), commit: false }); + expect(session.request).not.toHaveBeenCalled(); }); it("continues installer handoff when the final detach response is lost during shutdown", async () => { diff --git a/apps/desktop/frontend/desktopUpdates.ts b/apps/desktop/frontend/desktopUpdates.ts index efc1a8ee..18b121de 100644 --- a/apps/desktop/frontend/desktopUpdates.ts +++ b/apps/desktop/frontend/desktopUpdates.ts @@ -1,6 +1,6 @@ import type { InvokeArgs } from "@tauri-apps/api/core"; import type { Event, UnlistenFn } from "@tauri-apps/api/event"; -import type { AppServerSession } from "@openaide/app-server-client"; +import type { AppServerSession, UpdateShutdownPrepareResult } from "@openaide/app-server-client"; import { CLIENT_DETACH, CLIENT_UPDATE_SHUTDOWN_ABORT, @@ -52,7 +52,7 @@ export function createDesktopUpdates({ invoke: Invoke; listen: Listen; openReleaseNotes(version: string): Promise; - /** False while a paired computer's App Server is in use; an update then replaces only this shell. */ + /** False while a paired computer's App Server is in use; this computer's own then stops through the native side. */ ownsAppServer?: boolean; reload(): void; session: AppServerSession; @@ -67,6 +67,7 @@ export function createDesktopUpdates({ const run = async (command: string) => { publish(await invoke(command)); }; + const invokeLocal = (command: string, args: InvokeArgs) => invoke(command, args) as unknown as Promise; let disposed = false; let stop: UnlistenFn | undefined; @@ -94,8 +95,20 @@ export function createDesktopUpdates({ let prepared = false; let detached = false; if (!ownsAppServer) { - // The App Server belongs to another computer and keeps running there. - // Asking it to prepare for shutdown would stop that computer's work. + // The window uses another computer's App Server, which keeps running + // there. This computer's own App Server still runs behind the window, so + // its native client asks it to stop as this window would. + const readiness = await invokeLocal("desktop_update_prepare_local", { + attemptId, + stopActiveWork: options?.stopActiveWork ?? false, + }); + if (readiness.kind === "blocked") return readiness.reason; + try { + await invokeLocal("desktop_update_finish_local", { attemptId, commit: true }); + } catch (error) { + await invokeLocal("desktop_update_finish_local", { attemptId, commit: false }).catch(() => undefined); + throw error; + } console.info(`desktop_update_detach_started operation_id=${attemptId} app_server=remote`); const detachOutcome = await waitForFinalDetach(session.request(CLIENT_DETACH, {})); console.info(`desktop_update_detach_completed operation_id=${attemptId} outcome=${detachOutcome} app_server=remote`); @@ -104,6 +117,7 @@ export function createDesktopUpdates({ await run("desktop_install_update"); return "started"; } catch (error) { + // The window's own App Server is stopping; reload restarts it and reconnects. reload(); throw error; } diff --git a/apps/desktop/src-tauri/gen/schemas/linux-schema.json b/apps/desktop/src-tauri/gen/schemas/linux-schema.json new file mode 100644 index 00000000..444f38bf --- /dev/null +++ b/apps/desktop/src-tauri/gen/schemas/linux-schema.json @@ -0,0 +1,2879 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CapabilityFile", + "description": "Capability formats accepted in a capability file.", + "anyOf": [ + { + "description": "A single capability.", + "allOf": [ + { + "$ref": "#/definitions/Capability" + } + ] + }, + { + "description": "A list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + }, + { + "description": "A list of capabilities.", + "type": "object", + "required": [ + "capabilities" + ], + "properties": { + "capabilities": { + "description": "The list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + } + } + } + ], + "definitions": { + "Capability": { + "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", + "type": "object", + "required": [ + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", + "type": "string" + }, + "description": { + "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", + "default": "", + "type": "string" + }, + "remote": { + "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", + "anyOf": [ + { + "$ref": "#/definitions/CapabilityRemote" + }, + { + "type": "null" + } + ] + }, + "local": { + "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", + "default": true, + "type": "boolean" + }, + "windows": { + "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "webviews": { + "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionEntry" + }, + "uniqueItems": true + }, + "platforms": { + "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "CapabilityRemote": { + "description": "Configuration for remote URLs that are associated with the capability.", + "type": "object", + "required": [ + "urls" + ], + "properties": { + "urls": { + "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionEntry": { + "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", + "anyOf": [ + { + "description": "Reference a permission or permission set by identifier.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + { + "description": "Reference a permission or permission set by identifier and extends its scope.", + "type": "object", + "allOf": [ + { + "if": { + "properties": { + "identifier": { + "anyOf": [ + { + "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", + "type": "string", + "const": "opener:default", + "markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`" + }, + { + "description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.", + "type": "string", + "const": "opener:allow-default-urls", + "markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application." + }, + { + "description": "Enables the open_path command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-open-path", + "markdownDescription": "Enables the open_path command without any pre-configured scope." + }, + { + "description": "Enables the open_url command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-open-url", + "markdownDescription": "Enables the open_url command without any pre-configured scope." + }, + { + "description": "Enables the reveal_item_in_dir command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-reveal-item-in-dir", + "markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope." + }, + { + "description": "Denies the open_path command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-open-path", + "markdownDescription": "Denies the open_path command without any pre-configured scope." + }, + { + "description": "Denies the open_url command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-open-url", + "markdownDescription": "Denies the open_url command without any pre-configured scope." + }, + { + "description": "Denies the reveal_item_in_dir command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-reveal-item-in-dir", + "markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope." + } + ] + } + } + }, + "then": { + "properties": { + "allow": { + "items": { + "title": "OpenerScopeEntry", + "description": "Opener scope entry.", + "anyOf": [ + { + "type": "object", + "required": [ + "url" + ], + "properties": { + "app": { + "description": "An application to open this url with, for example: firefox.", + "allOf": [ + { + "$ref": "#/definitions/Application" + } + ] + }, + "url": { + "description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"", + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "path" + ], + "properties": { + "app": { + "description": "An application to open this path with, for example: xdg-open.", + "allOf": [ + { + "$ref": "#/definitions/Application" + } + ] + }, + "path": { + "description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", + "type": "string" + } + } + } + ] + } + }, + "deny": { + "items": { + "title": "OpenerScopeEntry", + "description": "Opener scope entry.", + "anyOf": [ + { + "type": "object", + "required": [ + "url" + ], + "properties": { + "app": { + "description": "An application to open this url with, for example: firefox.", + "allOf": [ + { + "$ref": "#/definitions/Application" + } + ] + }, + "url": { + "description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"", + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "path" + ], + "properties": { + "app": { + "description": "An application to open this path with, for example: xdg-open.", + "allOf": [ + { + "$ref": "#/definitions/Application" + } + ] + }, + "path": { + "description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", + "type": "string" + } + } + } + ] + } + } + } + }, + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + } + } + }, + { + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + } + ], + "required": [ + "identifier" + ] + } + ] + }, + "Identifier": { + "description": "Permission identifier", + "oneOf": [ + { + "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", + "type": "string", + "const": "core:default", + "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`", + "type": "string", + "const": "core:app:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`" + }, + { + "description": "Enables the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-hide", + "markdownDescription": "Enables the app_hide command without any pre-configured scope." + }, + { + "description": "Enables the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-show", + "markdownDescription": "Enables the app_show command without any pre-configured scope." + }, + { + "description": "Enables the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-bundle-type", + "markdownDescription": "Enables the bundle_type command without any pre-configured scope." + }, + { + "description": "Enables the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-default-window-icon", + "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." + }, + { + "description": "Enables the exit command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-exit", + "markdownDescription": "Enables the exit command without any pre-configured scope." + }, + { + "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-fetch-data-store-identifiers", + "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Enables the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-identifier", + "markdownDescription": "Enables the identifier command without any pre-configured scope." + }, + { + "description": "Enables the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-name", + "markdownDescription": "Enables the name command without any pre-configured scope." + }, + { + "description": "Enables the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-register-listener", + "markdownDescription": "Enables the register_listener command without any pre-configured scope." + }, + { + "description": "Enables the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-data-store", + "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." + }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, + { + "description": "Enables the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-app-theme", + "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-dock-visibility", + "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Enables the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-supports-multiple-windows", + "markdownDescription": "Enables the supports_multiple_windows command without any pre-configured scope." + }, + { + "description": "Enables the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-tauri-version", + "markdownDescription": "Enables the tauri_version command without any pre-configured scope." + }, + { + "description": "Enables the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-version", + "markdownDescription": "Enables the version command without any pre-configured scope." + }, + { + "description": "Denies the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-hide", + "markdownDescription": "Denies the app_hide command without any pre-configured scope." + }, + { + "description": "Denies the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-show", + "markdownDescription": "Denies the app_show command without any pre-configured scope." + }, + { + "description": "Denies the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-bundle-type", + "markdownDescription": "Denies the bundle_type command without any pre-configured scope." + }, + { + "description": "Denies the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-default-window-icon", + "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." + }, + { + "description": "Denies the exit command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-exit", + "markdownDescription": "Denies the exit command without any pre-configured scope." + }, + { + "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-fetch-data-store-identifiers", + "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Denies the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-identifier", + "markdownDescription": "Denies the identifier command without any pre-configured scope." + }, + { + "description": "Denies the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-name", + "markdownDescription": "Denies the name command without any pre-configured scope." + }, + { + "description": "Denies the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-register-listener", + "markdownDescription": "Denies the register_listener command without any pre-configured scope." + }, + { + "description": "Denies the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-data-store", + "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." + }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, + { + "description": "Denies the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-app-theme", + "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-dock-visibility", + "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Denies the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-supports-multiple-windows", + "markdownDescription": "Denies the supports_multiple_windows command without any pre-configured scope." + }, + { + "description": "Denies the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-tauri-version", + "markdownDescription": "Denies the tauri_version command without any pre-configured scope." + }, + { + "description": "Denies the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-version", + "markdownDescription": "Denies the version command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", + "type": "string", + "const": "core:event:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" + }, + { + "description": "Enables the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit", + "markdownDescription": "Enables the emit command without any pre-configured scope." + }, + { + "description": "Enables the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit-to", + "markdownDescription": "Enables the emit_to command without any pre-configured scope." + }, + { + "description": "Enables the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-listen", + "markdownDescription": "Enables the listen command without any pre-configured scope." + }, + { + "description": "Enables the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-unlisten", + "markdownDescription": "Enables the unlisten command without any pre-configured scope." + }, + { + "description": "Denies the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit", + "markdownDescription": "Denies the emit command without any pre-configured scope." + }, + { + "description": "Denies the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit-to", + "markdownDescription": "Denies the emit_to command without any pre-configured scope." + }, + { + "description": "Denies the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-listen", + "markdownDescription": "Denies the listen command without any pre-configured scope." + }, + { + "description": "Denies the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-unlisten", + "markdownDescription": "Denies the unlisten command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", + "type": "string", + "const": "core:image:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" + }, + { + "description": "Enables the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-bytes", + "markdownDescription": "Enables the from_bytes command without any pre-configured scope." + }, + { + "description": "Enables the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-path", + "markdownDescription": "Enables the from_path command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-rgba", + "markdownDescription": "Enables the rgba command without any pre-configured scope." + }, + { + "description": "Enables the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-size", + "markdownDescription": "Enables the size command without any pre-configured scope." + }, + { + "description": "Denies the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-bytes", + "markdownDescription": "Denies the from_bytes command without any pre-configured scope." + }, + { + "description": "Denies the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-path", + "markdownDescription": "Denies the from_path command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-rgba", + "markdownDescription": "Denies the rgba command without any pre-configured scope." + }, + { + "description": "Denies the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-size", + "markdownDescription": "Denies the size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", + "type": "string", + "const": "core:menu:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" + }, + { + "description": "Enables the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-append", + "markdownDescription": "Enables the append command without any pre-configured scope." + }, + { + "description": "Enables the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-create-default", + "markdownDescription": "Enables the create_default command without any pre-configured scope." + }, + { + "description": "Enables the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-get", + "markdownDescription": "Enables the get command without any pre-configured scope." + }, + { + "description": "Enables the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-insert", + "markdownDescription": "Enables the insert command without any pre-configured scope." + }, + { + "description": "Enables the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-checked", + "markdownDescription": "Enables the is_checked command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-items", + "markdownDescription": "Enables the items command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-popup", + "markdownDescription": "Enables the popup command without any pre-configured scope." + }, + { + "description": "Enables the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-prepend", + "markdownDescription": "Enables the prepend command without any pre-configured scope." + }, + { + "description": "Enables the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove", + "markdownDescription": "Enables the remove command without any pre-configured scope." + }, + { + "description": "Enables the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove-at", + "markdownDescription": "Enables the remove_at command without any pre-configured scope." + }, + { + "description": "Enables the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-accelerator", + "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." + }, + { + "description": "Enables the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-app-menu", + "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-help-menu-for-nsapp", + "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-window-menu", + "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-windows-menu-for-nsapp", + "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-checked", + "markdownDescription": "Enables the set_checked command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-text", + "markdownDescription": "Enables the set_text command without any pre-configured scope." + }, + { + "description": "Enables the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-text", + "markdownDescription": "Enables the text command without any pre-configured scope." + }, + { + "description": "Denies the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-append", + "markdownDescription": "Denies the append command without any pre-configured scope." + }, + { + "description": "Denies the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-create-default", + "markdownDescription": "Denies the create_default command without any pre-configured scope." + }, + { + "description": "Denies the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-get", + "markdownDescription": "Denies the get command without any pre-configured scope." + }, + { + "description": "Denies the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-insert", + "markdownDescription": "Denies the insert command without any pre-configured scope." + }, + { + "description": "Denies the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-checked", + "markdownDescription": "Denies the is_checked command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-items", + "markdownDescription": "Denies the items command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-popup", + "markdownDescription": "Denies the popup command without any pre-configured scope." + }, + { + "description": "Denies the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-prepend", + "markdownDescription": "Denies the prepend command without any pre-configured scope." + }, + { + "description": "Denies the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove", + "markdownDescription": "Denies the remove command without any pre-configured scope." + }, + { + "description": "Denies the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove-at", + "markdownDescription": "Denies the remove_at command without any pre-configured scope." + }, + { + "description": "Denies the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-accelerator", + "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." + }, + { + "description": "Denies the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-app-menu", + "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-help-menu-for-nsapp", + "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-window-menu", + "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-windows-menu-for-nsapp", + "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-checked", + "markdownDescription": "Denies the set_checked command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-text", + "markdownDescription": "Denies the set_text command without any pre-configured scope." + }, + { + "description": "Denies the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-text", + "markdownDescription": "Denies the text command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", + "type": "string", + "const": "core:path:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" + }, + { + "description": "Enables the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-basename", + "markdownDescription": "Enables the basename command without any pre-configured scope." + }, + { + "description": "Enables the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-dirname", + "markdownDescription": "Enables the dirname command without any pre-configured scope." + }, + { + "description": "Enables the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-extname", + "markdownDescription": "Enables the extname command without any pre-configured scope." + }, + { + "description": "Enables the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-is-absolute", + "markdownDescription": "Enables the is_absolute command without any pre-configured scope." + }, + { + "description": "Enables the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-join", + "markdownDescription": "Enables the join command without any pre-configured scope." + }, + { + "description": "Enables the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-normalize", + "markdownDescription": "Enables the normalize command without any pre-configured scope." + }, + { + "description": "Enables the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve", + "markdownDescription": "Enables the resolve command without any pre-configured scope." + }, + { + "description": "Enables the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve-directory", + "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." + }, + { + "description": "Denies the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-basename", + "markdownDescription": "Denies the basename command without any pre-configured scope." + }, + { + "description": "Denies the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-dirname", + "markdownDescription": "Denies the dirname command without any pre-configured scope." + }, + { + "description": "Denies the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-extname", + "markdownDescription": "Denies the extname command without any pre-configured scope." + }, + { + "description": "Denies the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-is-absolute", + "markdownDescription": "Denies the is_absolute command without any pre-configured scope." + }, + { + "description": "Denies the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-join", + "markdownDescription": "Denies the join command without any pre-configured scope." + }, + { + "description": "Denies the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-normalize", + "markdownDescription": "Denies the normalize command without any pre-configured scope." + }, + { + "description": "Denies the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve", + "markdownDescription": "Denies the resolve command without any pre-configured scope." + }, + { + "description": "Denies the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve-directory", + "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", + "type": "string", + "const": "core:resources:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`", + "type": "string", + "const": "core:tray:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`" + }, + { + "description": "Enables the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-get-by-id", + "markdownDescription": "Enables the get_by_id command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-remove-by-id", + "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-as-template", + "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-with-as-template", + "markdownDescription": "Enables the set_icon_with_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-menu", + "markdownDescription": "Enables the set_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-show-menu-on-left-click", + "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Enables the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-temp-dir-path", + "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-tooltip", + "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." + }, + { + "description": "Enables the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-visible", + "markdownDescription": "Enables the set_visible command without any pre-configured scope." + }, + { + "description": "Denies the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-get-by-id", + "markdownDescription": "Denies the get_by_id command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-remove-by-id", + "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-as-template", + "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-with-as-template", + "markdownDescription": "Denies the set_icon_with_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-menu", + "markdownDescription": "Denies the set_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-show-menu-on-left-click", + "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Denies the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-temp-dir-path", + "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-tooltip", + "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." + }, + { + "description": "Denies the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-visible", + "markdownDescription": "Denies the set_visible command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", + "type": "string", + "const": "core:webview:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" + }, + { + "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-clear-all-browsing-data", + "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Enables the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview", + "markdownDescription": "Enables the create_webview command without any pre-configured scope." + }, + { + "description": "Enables the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview-window", + "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." + }, + { + "description": "Enables the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-get-all-webviews", + "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-internal-toggle-devtools", + "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Enables the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-print", + "markdownDescription": "Enables the print command without any pre-configured scope." + }, + { + "description": "Enables the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-reparent", + "markdownDescription": "Enables the reparent command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-auto-resize", + "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-background-color", + "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-focus", + "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-position", + "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-size", + "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-zoom", + "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Enables the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-close", + "markdownDescription": "Enables the webview_close command without any pre-configured scope." + }, + { + "description": "Enables the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-hide", + "markdownDescription": "Enables the webview_hide command without any pre-configured scope." + }, + { + "description": "Enables the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-position", + "markdownDescription": "Enables the webview_position command without any pre-configured scope." + }, + { + "description": "Enables the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-show", + "markdownDescription": "Enables the webview_show command without any pre-configured scope." + }, + { + "description": "Enables the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-size", + "markdownDescription": "Enables the webview_size command without any pre-configured scope." + }, + { + "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-clear-all-browsing-data", + "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Denies the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview", + "markdownDescription": "Denies the create_webview command without any pre-configured scope." + }, + { + "description": "Denies the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview-window", + "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." + }, + { + "description": "Denies the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-get-all-webviews", + "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-internal-toggle-devtools", + "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Denies the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-print", + "markdownDescription": "Denies the print command without any pre-configured scope." + }, + { + "description": "Denies the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-reparent", + "markdownDescription": "Denies the reparent command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-auto-resize", + "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-background-color", + "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-focus", + "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-position", + "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-size", + "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-zoom", + "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Denies the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-close", + "markdownDescription": "Denies the webview_close command without any pre-configured scope." + }, + { + "description": "Denies the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-hide", + "markdownDescription": "Denies the webview_hide command without any pre-configured scope." + }, + { + "description": "Denies the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-position", + "markdownDescription": "Denies the webview_position command without any pre-configured scope." + }, + { + "description": "Denies the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-show", + "markdownDescription": "Denies the webview_show command without any pre-configured scope." + }, + { + "description": "Denies the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-size", + "markdownDescription": "Denies the webview_size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`", + "type": "string", + "const": "core:window:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-activity-name", + "markdownDescription": "Enables the activity_name command without any pre-configured scope." + }, + { + "description": "Enables the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-available-monitors", + "markdownDescription": "Enables the available_monitors command without any pre-configured scope." + }, + { + "description": "Enables the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-center", + "markdownDescription": "Enables the center command without any pre-configured scope." + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Enables the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-create", + "markdownDescription": "Enables the create command without any pre-configured scope." + }, + { + "description": "Enables the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-current-monitor", + "markdownDescription": "Enables the current_monitor command without any pre-configured scope." + }, + { + "description": "Enables the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-cursor-position", + "markdownDescription": "Enables the cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-destroy", + "markdownDescription": "Enables the destroy command without any pre-configured scope." + }, + { + "description": "Enables the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-get-all-windows", + "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." + }, + { + "description": "Enables the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-hide", + "markdownDescription": "Enables the hide command without any pre-configured scope." + }, + { + "description": "Enables the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-position", + "markdownDescription": "Enables the inner_position command without any pre-configured scope." + }, + { + "description": "Enables the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-size", + "markdownDescription": "Enables the inner_size command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-internal-toggle-maximize", + "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-always-on-top", + "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-closable", + "markdownDescription": "Enables the is_closable command without any pre-configured scope." + }, + { + "description": "Enables the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-decorated", + "markdownDescription": "Enables the is_decorated command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-focused", + "markdownDescription": "Enables the is_focused command without any pre-configured scope." + }, + { + "description": "Enables the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-fullscreen", + "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximizable", + "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximized", + "markdownDescription": "Enables the is_maximized command without any pre-configured scope." + }, + { + "description": "Enables the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimizable", + "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimized", + "markdownDescription": "Enables the is_minimized command without any pre-configured scope." + }, + { + "description": "Enables the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-resizable", + "markdownDescription": "Enables the is_resizable command without any pre-configured scope." + }, + { + "description": "Enables the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-visible", + "markdownDescription": "Enables the is_visible command without any pre-configured scope." + }, + { + "description": "Enables the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-maximize", + "markdownDescription": "Enables the maximize command without any pre-configured scope." + }, + { + "description": "Enables the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-minimize", + "markdownDescription": "Enables the minimize command without any pre-configured scope." + }, + { + "description": "Enables the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-monitor-from-point", + "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Enables the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-position", + "markdownDescription": "Enables the outer_position command without any pre-configured scope." + }, + { + "description": "Enables the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-size", + "markdownDescription": "Enables the outer_size command without any pre-configured scope." + }, + { + "description": "Enables the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-primary-monitor", + "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." + }, + { + "description": "Enables the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-request-user-attention", + "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." + }, + { + "description": "Enables the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scale-factor", + "markdownDescription": "Enables the scale_factor command without any pre-configured scope." + }, + { + "description": "Enables the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scene-identifier", + "markdownDescription": "Enables the scene_identifier command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-bottom", + "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-top", + "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-background-color", + "markdownDescription": "Enables the set_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-count", + "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-label", + "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." + }, + { + "description": "Enables the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-closable", + "markdownDescription": "Enables the set_closable command without any pre-configured scope." + }, + { + "description": "Enables the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-content-protected", + "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-grab", + "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-icon", + "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-position", + "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-visible", + "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Enables the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-decorations", + "markdownDescription": "Enables the set_decorations command without any pre-configured scope." + }, + { + "description": "Enables the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-effects", + "markdownDescription": "Enables the set_effects command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focus", + "markdownDescription": "Enables the set_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focusable", + "markdownDescription": "Enables the set_focusable command without any pre-configured scope." + }, + { + "description": "Enables the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen", + "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_fullscreen_on_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen-on-monitor", + "markdownDescription": "Enables the set_fullscreen_on_monitor command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-ignore-cursor-events", + "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Enables the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-max-size", + "markdownDescription": "Enables the set_max_size command without any pre-configured scope." + }, + { + "description": "Enables the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-maximizable", + "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-min-size", + "markdownDescription": "Enables the set_min_size command without any pre-configured scope." + }, + { + "description": "Enables the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-minimizable", + "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-overlay-icon", + "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-position", + "markdownDescription": "Enables the set_position command without any pre-configured scope." + }, + { + "description": "Enables the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-progress-bar", + "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Enables the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-resizable", + "markdownDescription": "Enables the set_resizable command without any pre-configured scope." + }, + { + "description": "Enables the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-shadow", + "markdownDescription": "Enables the set_shadow command without any pre-configured scope." + }, + { + "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-simple-fullscreen", + "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size", + "markdownDescription": "Enables the set_size command without any pre-configured scope." + }, + { + "description": "Enables the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size-constraints", + "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Enables the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-skip-taskbar", + "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Enables the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-theme", + "markdownDescription": "Enables the set_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title-bar-style", + "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-visible-on-all-workspaces", + "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Enables the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-show", + "markdownDescription": "Enables the show command without any pre-configured scope." + }, + { + "description": "Enables the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-dragging", + "markdownDescription": "Enables the start_dragging command without any pre-configured scope." + }, + { + "description": "Enables the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-resize-dragging", + "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Enables the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-theme", + "markdownDescription": "Enables the theme command without any pre-configured scope." + }, + { + "description": "Enables the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-title", + "markdownDescription": "Enables the title command without any pre-configured scope." + }, + { + "description": "Enables the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-toggle-maximize", + "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unmaximize", + "markdownDescription": "Enables the unmaximize command without any pre-configured scope." + }, + { + "description": "Enables the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unminimize", + "markdownDescription": "Enables the unminimize command without any pre-configured scope." + }, + { + "description": "Denies the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-activity-name", + "markdownDescription": "Denies the activity_name command without any pre-configured scope." + }, + { + "description": "Denies the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-available-monitors", + "markdownDescription": "Denies the available_monitors command without any pre-configured scope." + }, + { + "description": "Denies the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-center", + "markdownDescription": "Denies the center command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Denies the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-create", + "markdownDescription": "Denies the create command without any pre-configured scope." + }, + { + "description": "Denies the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-current-monitor", + "markdownDescription": "Denies the current_monitor command without any pre-configured scope." + }, + { + "description": "Denies the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-cursor-position", + "markdownDescription": "Denies the cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-destroy", + "markdownDescription": "Denies the destroy command without any pre-configured scope." + }, + { + "description": "Denies the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-get-all-windows", + "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." + }, + { + "description": "Denies the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-hide", + "markdownDescription": "Denies the hide command without any pre-configured scope." + }, + { + "description": "Denies the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-position", + "markdownDescription": "Denies the inner_position command without any pre-configured scope." + }, + { + "description": "Denies the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-size", + "markdownDescription": "Denies the inner_size command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-internal-toggle-maximize", + "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-always-on-top", + "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-closable", + "markdownDescription": "Denies the is_closable command without any pre-configured scope." + }, + { + "description": "Denies the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-decorated", + "markdownDescription": "Denies the is_decorated command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-focused", + "markdownDescription": "Denies the is_focused command without any pre-configured scope." + }, + { + "description": "Denies the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-fullscreen", + "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximizable", + "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximized", + "markdownDescription": "Denies the is_maximized command without any pre-configured scope." + }, + { + "description": "Denies the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimizable", + "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimized", + "markdownDescription": "Denies the is_minimized command without any pre-configured scope." + }, + { + "description": "Denies the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-resizable", + "markdownDescription": "Denies the is_resizable command without any pre-configured scope." + }, + { + "description": "Denies the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-visible", + "markdownDescription": "Denies the is_visible command without any pre-configured scope." + }, + { + "description": "Denies the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-maximize", + "markdownDescription": "Denies the maximize command without any pre-configured scope." + }, + { + "description": "Denies the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-minimize", + "markdownDescription": "Denies the minimize command without any pre-configured scope." + }, + { + "description": "Denies the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-monitor-from-point", + "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Denies the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-position", + "markdownDescription": "Denies the outer_position command without any pre-configured scope." + }, + { + "description": "Denies the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-size", + "markdownDescription": "Denies the outer_size command without any pre-configured scope." + }, + { + "description": "Denies the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-primary-monitor", + "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." + }, + { + "description": "Denies the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-request-user-attention", + "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." + }, + { + "description": "Denies the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scale-factor", + "markdownDescription": "Denies the scale_factor command without any pre-configured scope." + }, + { + "description": "Denies the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scene-identifier", + "markdownDescription": "Denies the scene_identifier command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-bottom", + "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-top", + "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-background-color", + "markdownDescription": "Denies the set_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-count", + "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-label", + "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." + }, + { + "description": "Denies the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-closable", + "markdownDescription": "Denies the set_closable command without any pre-configured scope." + }, + { + "description": "Denies the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-content-protected", + "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-grab", + "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-icon", + "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-position", + "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-visible", + "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Denies the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-decorations", + "markdownDescription": "Denies the set_decorations command without any pre-configured scope." + }, + { + "description": "Denies the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-effects", + "markdownDescription": "Denies the set_effects command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focus", + "markdownDescription": "Denies the set_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focusable", + "markdownDescription": "Denies the set_focusable command without any pre-configured scope." + }, + { + "description": "Denies the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen", + "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_fullscreen_on_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen-on-monitor", + "markdownDescription": "Denies the set_fullscreen_on_monitor command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-ignore-cursor-events", + "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Denies the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-max-size", + "markdownDescription": "Denies the set_max_size command without any pre-configured scope." + }, + { + "description": "Denies the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-maximizable", + "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-min-size", + "markdownDescription": "Denies the set_min_size command without any pre-configured scope." + }, + { + "description": "Denies the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-minimizable", + "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-overlay-icon", + "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-position", + "markdownDescription": "Denies the set_position command without any pre-configured scope." + }, + { + "description": "Denies the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-progress-bar", + "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Denies the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-resizable", + "markdownDescription": "Denies the set_resizable command without any pre-configured scope." + }, + { + "description": "Denies the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-shadow", + "markdownDescription": "Denies the set_shadow command without any pre-configured scope." + }, + { + "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-simple-fullscreen", + "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size", + "markdownDescription": "Denies the set_size command without any pre-configured scope." + }, + { + "description": "Denies the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size-constraints", + "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Denies the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-skip-taskbar", + "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Denies the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-theme", + "markdownDescription": "Denies the set_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title-bar-style", + "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-visible-on-all-workspaces", + "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Denies the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-show", + "markdownDescription": "Denies the show command without any pre-configured scope." + }, + { + "description": "Denies the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-dragging", + "markdownDescription": "Denies the start_dragging command without any pre-configured scope." + }, + { + "description": "Denies the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-resize-dragging", + "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Denies the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-theme", + "markdownDescription": "Denies the theme command without any pre-configured scope." + }, + { + "description": "Denies the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-title", + "markdownDescription": "Denies the title command without any pre-configured scope." + }, + { + "description": "Denies the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-toggle-maximize", + "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unmaximize", + "markdownDescription": "Denies the unmaximize command without any pre-configured scope." + }, + { + "description": "Denies the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unminimize", + "markdownDescription": "Denies the unminimize command without any pre-configured scope." + }, + { + "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "type": "string", + "const": "dialog:default", + "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" + }, + { + "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-ask", + "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-confirm", + "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-message", + "markdownDescription": "Enables the message command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-save", + "markdownDescription": "Enables the save command without any pre-configured scope." + }, + { + "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-ask", + "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-confirm", + "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-message", + "markdownDescription": "Denies the message command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-save", + "markdownDescription": "Denies the save command without any pre-configured scope." + }, + { + "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`", + "type": "string", + "const": "notification:default", + "markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`" + }, + { + "description": "Enables the batch command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-batch", + "markdownDescription": "Enables the batch command without any pre-configured scope." + }, + { + "description": "Enables the cancel command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-cancel", + "markdownDescription": "Enables the cancel command without any pre-configured scope." + }, + { + "description": "Enables the check_permissions command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-check-permissions", + "markdownDescription": "Enables the check_permissions command without any pre-configured scope." + }, + { + "description": "Enables the create_channel command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-create-channel", + "markdownDescription": "Enables the create_channel command without any pre-configured scope." + }, + { + "description": "Enables the delete_channel command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-delete-channel", + "markdownDescription": "Enables the delete_channel command without any pre-configured scope." + }, + { + "description": "Enables the get_active command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-get-active", + "markdownDescription": "Enables the get_active command without any pre-configured scope." + }, + { + "description": "Enables the get_pending command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-get-pending", + "markdownDescription": "Enables the get_pending command without any pre-configured scope." + }, + { + "description": "Enables the is_permission_granted command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-is-permission-granted", + "markdownDescription": "Enables the is_permission_granted command without any pre-configured scope." + }, + { + "description": "Enables the list_channels command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-list-channels", + "markdownDescription": "Enables the list_channels command without any pre-configured scope." + }, + { + "description": "Enables the notify command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-notify", + "markdownDescription": "Enables the notify command without any pre-configured scope." + }, + { + "description": "Enables the permission_state command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-permission-state", + "markdownDescription": "Enables the permission_state command without any pre-configured scope." + }, + { + "description": "Enables the register_action_types command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-register-action-types", + "markdownDescription": "Enables the register_action_types command without any pre-configured scope." + }, + { + "description": "Enables the register_listener command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-register-listener", + "markdownDescription": "Enables the register_listener command without any pre-configured scope." + }, + { + "description": "Enables the remove_active command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-remove-active", + "markdownDescription": "Enables the remove_active command without any pre-configured scope." + }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, + { + "description": "Enables the request_permission command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-request-permission", + "markdownDescription": "Enables the request_permission command without any pre-configured scope." + }, + { + "description": "Enables the show command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-show", + "markdownDescription": "Enables the show command without any pre-configured scope." + }, + { + "description": "Denies the batch command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-batch", + "markdownDescription": "Denies the batch command without any pre-configured scope." + }, + { + "description": "Denies the cancel command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-cancel", + "markdownDescription": "Denies the cancel command without any pre-configured scope." + }, + { + "description": "Denies the check_permissions command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-check-permissions", + "markdownDescription": "Denies the check_permissions command without any pre-configured scope." + }, + { + "description": "Denies the create_channel command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-create-channel", + "markdownDescription": "Denies the create_channel command without any pre-configured scope." + }, + { + "description": "Denies the delete_channel command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-delete-channel", + "markdownDescription": "Denies the delete_channel command without any pre-configured scope." + }, + { + "description": "Denies the get_active command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-get-active", + "markdownDescription": "Denies the get_active command without any pre-configured scope." + }, + { + "description": "Denies the get_pending command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-get-pending", + "markdownDescription": "Denies the get_pending command without any pre-configured scope." + }, + { + "description": "Denies the is_permission_granted command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-is-permission-granted", + "markdownDescription": "Denies the is_permission_granted command without any pre-configured scope." + }, + { + "description": "Denies the list_channels command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-list-channels", + "markdownDescription": "Denies the list_channels command without any pre-configured scope." + }, + { + "description": "Denies the notify command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-notify", + "markdownDescription": "Denies the notify command without any pre-configured scope." + }, + { + "description": "Denies the permission_state command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-permission-state", + "markdownDescription": "Denies the permission_state command without any pre-configured scope." + }, + { + "description": "Denies the register_action_types command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-register-action-types", + "markdownDescription": "Denies the register_action_types command without any pre-configured scope." + }, + { + "description": "Denies the register_listener command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-register-listener", + "markdownDescription": "Denies the register_listener command without any pre-configured scope." + }, + { + "description": "Denies the remove_active command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-remove-active", + "markdownDescription": "Denies the remove_active command without any pre-configured scope." + }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, + { + "description": "Denies the request_permission command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-request-permission", + "markdownDescription": "Denies the request_permission command without any pre-configured scope." + }, + { + "description": "Denies the show command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-show", + "markdownDescription": "Denies the show command without any pre-configured scope." + }, + { + "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", + "type": "string", + "const": "opener:default", + "markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`" + }, + { + "description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.", + "type": "string", + "const": "opener:allow-default-urls", + "markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application." + }, + { + "description": "Enables the open_path command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-open-path", + "markdownDescription": "Enables the open_path command without any pre-configured scope." + }, + { + "description": "Enables the open_url command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-open-url", + "markdownDescription": "Enables the open_url command without any pre-configured scope." + }, + { + "description": "Enables the reveal_item_in_dir command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-reveal-item-in-dir", + "markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope." + }, + { + "description": "Denies the open_path command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-open-path", + "markdownDescription": "Denies the open_path command without any pre-configured scope." + }, + { + "description": "Denies the open_url command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-open-url", + "markdownDescription": "Denies the open_url command without any pre-configured scope." + }, + { + "description": "Denies the reveal_item_in_dir command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-reveal-item-in-dir", + "markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope." + }, + { + "description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`", + "type": "string", + "const": "updater:default", + "markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`" + }, + { + "description": "Enables the check command without any pre-configured scope.", + "type": "string", + "const": "updater:allow-check", + "markdownDescription": "Enables the check command without any pre-configured scope." + }, + { + "description": "Enables the download command without any pre-configured scope.", + "type": "string", + "const": "updater:allow-download", + "markdownDescription": "Enables the download command without any pre-configured scope." + }, + { + "description": "Enables the download_and_install command without any pre-configured scope.", + "type": "string", + "const": "updater:allow-download-and-install", + "markdownDescription": "Enables the download_and_install command without any pre-configured scope." + }, + { + "description": "Enables the install command without any pre-configured scope.", + "type": "string", + "const": "updater:allow-install", + "markdownDescription": "Enables the install command without any pre-configured scope." + }, + { + "description": "Denies the check command without any pre-configured scope.", + "type": "string", + "const": "updater:deny-check", + "markdownDescription": "Denies the check command without any pre-configured scope." + }, + { + "description": "Denies the download command without any pre-configured scope.", + "type": "string", + "const": "updater:deny-download", + "markdownDescription": "Denies the download command without any pre-configured scope." + }, + { + "description": "Denies the download_and_install command without any pre-configured scope.", + "type": "string", + "const": "updater:deny-download-and-install", + "markdownDescription": "Denies the download_and_install command without any pre-configured scope." + }, + { + "description": "Denies the install command without any pre-configured scope.", + "type": "string", + "const": "updater:deny-install", + "markdownDescription": "Denies the install command without any pre-configured scope." + } + ] + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "Application": { + "description": "Opener scope application.", + "anyOf": [ + { + "description": "Open in default application.", + "type": "null" + }, + { + "description": "If true, allow open with any application.", + "type": "boolean" + }, + { + "description": "Allow specific application to open with.", + "type": "string" + } + ] + } + } +} \ No newline at end of file diff --git a/apps/desktop/src-tauri/src/desktop_app_server_hold.rs b/apps/desktop/src-tauri/src/desktop_app_server_hold.rs index ae2e39a0..b7379a1d 100644 --- a/apps/desktop/src-tauri/src/desktop_app_server_hold.rs +++ b/apps/desktop/src-tauri/src/desktop_app_server_hold.rs @@ -11,9 +11,12 @@ //! another owner when the window prepares a Desktop Update; an ordinary client //! here would refuse every update with `otherClients`. //! -//! Quit and Desktop Update end the hold explicitly, after the window's client has -//! detached, so the App Server still receives its last-client signal at once. A -//! Desktop that dies without that stops heartbeating and expires like any client. +//! The hold stays on this computer's App Server while the window uses a paired +//! computer's (ADR-0070), so this computer's work keeps running; a Desktop Update +//! then asks it to stop through this client. Quit and Desktop Update end the hold +//! explicitly, after the window's client has detached, so the App Server still +//! receives its last-client signal at once. A Desktop that dies without that +//! stops heartbeating and expires like any client. use std::io::{Read, Write}; use std::net::{SocketAddr, TcpStream}; @@ -42,32 +45,33 @@ pub(crate) enum HoldError { Refused, /// The endpoint or the answer could not be read as the protocol. Malformed, + /// No App Server is held yet, so there is nobody to ask. + NotHeld, } impl HoldError { /// Stable, content-free name for diagnostics. - fn class(&self) -> &'static str { + pub(crate) fn class(&self) -> &'static str { match self { Self::Unreachable => "unreachable", Self::Refused => "refused", Self::Malformed => "malformed", + Self::NotHeld => "not_held", } } } -/// Starts the hold for the App Server this Desktop owns, following every replacement. +/// Starts the hold for this computer's App Server, following every replacement. pub(crate) fn install(app: &tauri::App) { use tauri::Manager; let hold = AppServerHold::new(); app.manage(hold.clone()); let app = app.handle().clone(); hold.start(move || { - let desktop = app.state::(); - // A paired computer's App Server is kept running by a shell on that computer. - if !desktop.owns_app_server() { - return None; - } - let connection = desktop.connection.lock().ok()?.clone()?; + // This computer's App Server, whichever computer the window uses (ADR-0070). + let connection = app + .state::() + .local()?; Some(HoldTarget { endpoint_url: connection.endpoint_url, auth_token: connection.auth_token, @@ -85,6 +89,65 @@ pub(crate) async fn complete_desktop_quit(app: tauri::AppHandle) { app.exit(0); } +/// Desktop Update's readiness check for this computer's App Server while the +/// window uses another computer. The update replaces this App Server too, so its +/// work and other clients decide as they do while it is in use. Nothing held +/// means nothing runs here that an update could stop. +#[tauri::command] +pub(crate) async fn desktop_update_prepare_local( + app: tauri::AppHandle, + attempt_id: String, + stop_active_work: bool, +) -> Result { + let result = call_local( + &app, + "client/updateShutdownPrepare", + json!({ "attemptId": attempt_id, "stopActiveWork": stop_active_work }), + ) + .await; + match result { + Ok(answer) => Ok(answer), + Err(HoldError::NotHeld) => Ok(json!({ "kind": "ready" })), + Err(_) => Err("OpenAIDE could not check this computer's work before the update.".to_string()), + } +} + +/// Commits or withdraws the readiness `desktop_update_prepare_local` obtained. +#[tauri::command] +pub(crate) async fn desktop_update_finish_local( + app: tauri::AppHandle, + attempt_id: String, + commit: bool, +) -> Result<(), String> { + let method = if commit { + "client/updateShutdownCommit" + } else { + "client/updateShutdownAbort" + }; + match call_local(&app, method, json!({ "attemptId": attempt_id })).await { + Ok(_) | Err(HoldError::NotHeld) => Ok(()), + Err(_) => Err("OpenAIDE could not prepare this computer for the update.".to_string()), + } +} + +async fn call_local(app: &tauri::AppHandle, method: &'static str, params: Value) -> Result { + use tauri::Manager; + let hold = app.state::().inner().clone(); + let started_at = Instant::now(); + let result = tauri::async_runtime::spawn_blocking(move || hold.call(method, params)) + .await + .unwrap_or(Err(HoldError::Unreachable)); + desktop_log!( + "desktop_update_local_request_completed method={method} outcome={} duration_ms={}", + match &result { + Ok(_) => "success", + Err(error) => error.class(), + }, + started_at.elapsed().as_millis() + ); + result +} + #[derive(Default)] struct HoldState { held: Option, @@ -130,8 +193,8 @@ impl AppServerHold { } if state.held != target { if let Some(previous) = state.held.take() { - // A replaced App Server is gone and refuses nothing; one Desktop no - // longer owns (a paired computer is in use) is told so it may stop. + // A replaced App Server is gone and refuses nothing; a live one is + // told, so a hold that moves never leaves a client behind. let _ = self.request(&mut state, &previous, "client/detach", json!({})); } state.failures = 0; @@ -161,7 +224,7 @@ impl AppServerHold { return; }; match self.request(&mut state, &target, "client/heartbeat", json!({})) { - Ok(()) => { + Ok(_) => { if state.failures > 0 { desktop_log!( "desktop_app_server_hold_heartbeat_recovered attempt_count={}", @@ -204,11 +267,12 @@ impl AppServerHold { }; let started_at = Instant::now(); let outcome = match self.request(&mut state, &target, "client/detach", json!({})) { - Ok(()) => "detached", + Ok(_) => "detached", // The App Server may exit on its final detach before the answer is readable. Err(HoldError::Unreachable) => "unreachable", Err(HoldError::Refused) => "refused", Err(HoldError::Malformed) => "malformed", + Err(HoldError::NotHeld) => "not_held", }; desktop_log!( "desktop_app_server_hold_ended reason_code={reason_code} outcome={outcome} duration_ms={}", @@ -216,6 +280,15 @@ impl AppServerHold { ); } + /// One request to the held App Server as this native client, answered with + /// its result. Desktop Update asks through it while the window uses another + /// computer and so has no client here. + pub(crate) fn call(&self, method: &str, params: Value) -> Result { + let mut state = self.state.lock().map_err(|_| HoldError::Unreachable)?; + let target = state.held.clone().ok_or(HoldError::NotHeld)?; + self.request(&mut state, &target, method, params) + } + fn initialize(&self, state: &mut HoldState, target: &HoldTarget) -> Result<(), HoldError> { self.request( state, @@ -235,6 +308,7 @@ impl AppServerHold { "role": "shellHold", }), ) + .map(|_| ()) } /// One plain HTTP request to the loopback endpoint, as a same-machine client with no window. @@ -244,38 +318,56 @@ impl AppServerHold { target: &HoldTarget, method: &str, params: Value, - ) -> Result<(), HoldError> { - let (address, path) = - loopback_endpoint(&target.endpoint_url).ok_or(HoldError::Malformed)?; + ) -> Result { state.next_request += 1; let id = format!("desktop-native-{}", state.next_request); - let body = - json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }).to_string(); - let wire = format!( - "POST {path} HTTP/1.1\r\nHost: {address}\r\nAuthorization: Bearer {}\r\nX-OpenAIDE-Connection-Id: {}\r\nX-OpenAIDE-Client-Instance-Id: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - target.auth_token, - self.client_instance_id, - self.client_instance_id, - body.len(), - ); - let mut stream = TcpStream::connect_timeout(&address, REQUEST_TIMEOUT) - .map_err(|_| HoldError::Unreachable)?; - stream - .set_read_timeout(Some(REQUEST_TIMEOUT)) - .and_then(|()| stream.set_write_timeout(Some(REQUEST_TIMEOUT))) - .map_err(|_| HoldError::Unreachable)?; - stream - .write_all(wire.as_bytes()) - .map_err(|_| HoldError::Unreachable)?; - let mut response = Vec::new(); - stream - .take(MAX_RESPONSE_BYTES) - .read_to_end(&mut response) - .map_err(|_| HoldError::Unreachable)?; - read_reply(&response, &id) + post_loopback( + target, + &self.client_instance_id, + &id, + method, + params, + REQUEST_TIMEOUT, + ) } } +/// One JSON-RPC request to a same-machine App Server over plain HTTP, as the +/// windowless client `client_instance_id`; returns the reply's result. `timeout` +/// bounds connecting, writing, and reading, so a held request needs a longer one. +pub(crate) fn post_loopback( + target: &HoldTarget, + client_instance_id: &str, + request_id: &str, + method: &str, + params: Value, + timeout: Duration, +) -> Result { + let (address, path) = loopback_endpoint(&target.endpoint_url).ok_or(HoldError::Malformed)?; + let body = json!({ "jsonrpc": "2.0", "id": request_id, "method": method, "params": params }) + .to_string(); + let wire = format!( + "POST {path} HTTP/1.1\r\nHost: {address}\r\nAuthorization: Bearer {}\r\nX-OpenAIDE-Connection-Id: {client_instance_id}\r\nX-OpenAIDE-Client-Instance-Id: {client_instance_id}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + target.auth_token, + body.len(), + ); + let mut stream = TcpStream::connect_timeout(&address, REQUEST_TIMEOUT.min(timeout)) + .map_err(|_| HoldError::Unreachable)?; + stream + .set_read_timeout(Some(timeout)) + .and_then(|()| stream.set_write_timeout(Some(timeout))) + .map_err(|_| HoldError::Unreachable)?; + stream + .write_all(wire.as_bytes()) + .map_err(|_| HoldError::Unreachable)?; + let mut response = Vec::new(); + stream + .take(MAX_RESPONSE_BYTES) + .read_to_end(&mut response) + .map_err(|_| HoldError::Unreachable)?; + read_reply(&response, request_id) +} + /// The App Server's published address. Any host but loopback is refused: the token must not leave this machine. fn loopback_endpoint(endpoint_url: &str) -> Option<(SocketAddr, String)> { let rest = endpoint_url.strip_prefix("http://")?; @@ -290,8 +382,9 @@ fn loopback_endpoint(endpoint_url: &str) -> Option<(SocketAddr, String)> { .then(|| (address, path.to_string())) } -/// The App Server answers with one message or a list: the reply, then whatever was queued for this client. -fn read_reply(response: &[u8], id: &str) -> Result<(), HoldError> { +/// The App Server answers with one message or a list: the reply, then whatever +/// was queued for this client. Returns the reply's result. +fn read_reply(response: &[u8], id: &str) -> Result { let text = std::str::from_utf8(response).map_err(|_| HoldError::Malformed)?; let (head, body) = text.split_once("\r\n\r\n").ok_or(HoldError::Unreachable)?; let status = head @@ -314,7 +407,14 @@ fn read_reply(response: &[u8], id: &str) -> Result<(), HoldError> { if reply.get("error").is_some() { return Err(HoldError::Refused); } - Ok(()) + // The App Server wraps every result in a response envelope (`{ result, meta? }`). + let envelope = reply.get("result").cloned().unwrap_or(Value::Null); + match envelope { + Value::Object(mut fields) if fields.contains_key("result") => { + Ok(fields.remove("result").unwrap_or(Value::Null)) + } + bare => Ok(bare), + } } #[cfg(test)] diff --git a/apps/desktop/src-tauri/src/desktop_app_server_hold_tests.rs b/apps/desktop/src-tauri/src/desktop_app_server_hold_tests.rs index 56283e1d..697ca94e 100644 --- a/apps/desktop/src-tauri/src/desktop_app_server_hold_tests.rs +++ b/apps/desktop/src-tauri/src/desktop_app_server_hold_tests.rs @@ -199,3 +199,27 @@ fn sends_the_token_only_to_loopback() { assert!(loopback_endpoint("https://127.0.0.1:43123/rpc").is_none()); assert!(loopback_endpoint("http://localhost:43123/rpc").is_none()); } + +#[test] +fn asks_the_held_app_server_as_its_own_client() { + let server = FakeAppServer::start("token-one"); + let hold = AppServerHold::new(); + let params = serde_json::json!({ "attemptId": "update-1", "stopActiveWork": false }); + + // Before this computer's App Server started there is nothing to ask. + assert_eq!( + hold.call("client/updateShutdownPrepare", params.clone()), + Err(super::HoldError::NotHeld) + ); + hold.sync(Some(server.target.clone())); + let initialize = server.received.try_recv().expect("initialize request"); + + let answer = hold + .call("client/updateShutdownPrepare", params.clone()) + .expect("answer"); + let asked = server.received.try_recv().expect("prepare request"); + assert_eq!(answer, serde_json::json!({})); + assert_eq!(asked.method, "client/updateShutdownPrepare"); + assert_eq!(asked.params, params); + assert_eq!(asked.client_instance_id, initialize.client_instance_id); +} diff --git a/apps/desktop/src-tauri/src/desktop_app_server_launch.rs b/apps/desktop/src-tauri/src/desktop_app_server_launch.rs new file mode 100644 index 00000000..229b9be4 --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_app_server_launch.rs @@ -0,0 +1,237 @@ +//! Starts this computer's App Server for the Desktop and reads where it listens. +//! +//! The App Server prints one handoff line with its loopback endpoint and token. +//! A WSL environment runs the same handoff inside the distribution. + +use std::io::{BufRead, BufReader, Read}; +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; + +#[cfg(target_os = "windows")] +use crate::CREATE_NO_WINDOW; +use crate::desktop_connection::emit_bootstrap_progress; +#[cfg(target_os = "macos")] +use crate::macos_shell_path; +use crate::desktop_runtime::{DesktopRuntimeEnvironment, DesktopRuntimePaths}; +use crate::startup_process::StartupChild; +use crate::wsl_runtime::launch_wsl_app_server_handoff; + +const HANDOFF_TIMEOUT: Duration = Duration::from_secs(60); +const MAX_HANDOFF_LINE_BYTES: usize = 8 * 1024; +const MIN_AUTH_TOKEN_BYTES: usize = 32; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LocalHttpConnection { + pub(crate) kind: String, + pub(crate) endpoint_url: String, + pub(crate) auth_token: String, +} + +pub(crate) struct HandoffFailure { + pub(crate) stage: &'static str, + pub(crate) user_message: String, +} + +/// Runs the App Server handoff and always reports one terminal line. Callers own the +/// decision to ask for a process; the handoff itself may elect an App Server that is +/// already running. Progress goes to the startup window only while it is listening. +pub(crate) async fn launch_app_server_for_desktop( + runtime_paths: DesktopRuntimePaths, + wsl_resource_binary: PathBuf, + environment: DesktopRuntimeEnvironment, + progress_app: Option, + operation_id: String, +) -> Result { + let started_at = Instant::now(); + desktop_log!("desktop_app_server_handoff_started operation_id={operation_id}"); + let joined = tauri::async_runtime::spawn_blocking(move || { + launch_app_server_handoff( + &runtime_paths, + &environment, + &wsl_resource_binary, + |stage, message| { + if let Some(app) = progress_app.as_ref() { + emit_bootstrap_progress(app, stage, message); + } + }, + ) + }) + .await; + match joined { + Err(_) => { + desktop_log!( + "desktop_app_server_handoff_completed operation_id={operation_id} outcome=failure stage=task_join duration_ms={}", + started_at.elapsed().as_millis() + ); + Err("App Server handoff task failed".to_string()) + } + Ok(Err(error)) => { + desktop_log!( + "desktop_app_server_handoff_completed operation_id={operation_id} outcome=failure stage={} duration_ms={}", + error.stage, + started_at.elapsed().as_millis() + ); + Err(error.user_message) + } + Ok(Ok(connection)) => { + desktop_log!( + "desktop_app_server_handoff_completed operation_id={operation_id} outcome=success duration_ms={}", + started_at.elapsed().as_millis() + ); + Ok(connection) + } + } +} + +/// A live App Server answers HTTP on its published loopback endpoint. Any HTTP status +/// proves a listener; the endpoint is loopback-only and token-guarded, so a 401 still +/// means the process is there. Only a connection failure means the process is gone. +pub(crate) async fn app_server_endpoint_reachable(connection: &LocalHttpConnection) -> bool { + let Ok(url) = url::Url::parse(&connection.endpoint_url) else { + return false; + }; + let Ok(client) = reqwest::Client::builder() + .connect_timeout(Duration::from_millis(750)) + .timeout(Duration::from_secs(2)) + .no_proxy() + .build() + else { + return false; + }; + client + .get(url) + .bearer_auth(&connection.auth_token) + .send() + .await + .is_ok() +} + +fn launch_app_server_handoff( + runtime_paths: &DesktopRuntimePaths, + environment: &DesktopRuntimeEnvironment, + wsl_resource_binary: &Path, + progress: impl Fn(&'static str, String), +) -> Result { + if let DesktopRuntimeEnvironment::Wsl { distro } = environment { + return launch_wsl_app_server_handoff(distro, wsl_resource_binary, progress); + } + create_directory(&runtime_paths.storage_root, "storage_create")?; + create_directory(&runtime_paths.runtime_root, "runtime_create")?; + + let mut command = Command::new(&runtime_paths.app_server_binary); + #[cfg(target_os = "macos")] + macos_shell_path::apply_to(&mut command).map_err(|_| { + failure( + "shell_environment", + "OpenAIDE could not load your terminal PATH. Check your shell startup files and retry.", + ) + })?; + command + .env("OPENAIDE_APP_SERVER_PROTOCOL", "app-server-handoff") + .env("OPENAIDE_STORAGE_ROOT", &runtime_paths.storage_root) + .env("OPENAIDE_RUNTIME_ROOT", &runtime_paths.runtime_root) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + #[cfg(target_os = "windows")] + // The App Server remains a console binary for VSIX use, but Desktop owns it + // as a background child and must not flash a second console window. + command.creation_flags(CREATE_NO_WINDOW); + let mut child = command + .spawn() + .map_err(|_| failure("process_spawn", "OpenAIDE could not start its App Server."))?; + let mut startup = StartupChild::new(&mut child); + let stdout = startup.take_stdout().ok_or_else(|| { + failure( + "stdout_missing", + "The App Server did not provide startup information.", + ) + })?; + let line = read_handoff_line(stdout)?; + let connection: LocalHttpConnection = serde_json::from_str(line.trim()).map_err(|_| { + failure( + "json_invalid", + "The App Server returned invalid startup information.", + ) + })?; + validate_connection(&connection)?; + startup.accept(); + thread::spawn(move || { + let _ = child.wait(); + }); + Ok(connection) +} + +pub(crate) fn read_handoff_line(stdout: impl Read + Send + 'static) -> Result { + let (sender, receiver) = std::sync::mpsc::channel(); + thread::spawn(move || { + let mut line = String::new(); + let result = BufReader::new(stdout) + .take((MAX_HANDOFF_LINE_BYTES + 1) as u64) + .read_line(&mut line) + .map(|_| line); + let _ = sender.send(result); + }); + match receiver.recv_timeout(HANDOFF_TIMEOUT) { + Ok(Ok(line)) if !line.trim().is_empty() && line.len() <= MAX_HANDOFF_LINE_BYTES => Ok(line), + Ok(Ok(_)) => Err(failure( + "line_invalid", + "The App Server returned invalid startup information.", + )), + Ok(Err(_)) => Err(failure( + "line_read", + "OpenAIDE could not read App Server startup information.", + )), + Err(_) => Err(failure( + "timeout", + "The App Server did not become ready in time.", + )), + } +} + +fn create_directory(path: &std::path::Path, stage: &'static str) -> Result<(), HandoffFailure> { + std::fs::create_dir_all(path) + .map_err(|_| failure(stage, "OpenAIDE could not prepare its local storage.")) +} + +pub(crate) fn validate_connection(connection: &LocalHttpConnection) -> Result<(), HandoffFailure> { + if connection.kind != "localHttp" || connection.auth_token.len() < MIN_AUTH_TOKEN_BYTES { + return Err(failure( + "connection_invalid", + "The App Server returned invalid startup information.", + )); + } + let endpoint = url::Url::parse(&connection.endpoint_url).map_err(|_| { + failure( + "endpoint_invalid", + "The App Server returned an invalid local endpoint.", + ) + })?; + let loopback = endpoint.scheme() == "http" + && matches!(endpoint.host_str(), Some("127.0.0.1" | "localhost" | "::1")); + if !loopback { + return Err(failure( + "endpoint_not_loopback", + "The App Server returned an unsafe endpoint.", + )); + } + Ok(()) +} + +pub(crate) fn failure(stage: &'static str, user_message: &str) -> HandoffFailure { + HandoffFailure { + stage, + user_message: user_message.to_string(), + } +} + +#[cfg(test)] +#[path = "desktop_server_recovery_tests.rs"] +mod tests; diff --git a/apps/desktop/src-tauri/src/desktop_connection.rs b/apps/desktop/src-tauri/src/desktop_connection.rs new file mode 100644 index 00000000..53b7b2c5 --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_connection.rs @@ -0,0 +1,412 @@ +//! Which App Server the Desktop window uses (ADR-0070). +//! +//! This computer's App Server starts with the Desktop and stays held until the +//! Desktop quits, whichever computer is in use, so switching away never stops +//! its work or its own Remote Devices. The window uses one App Server at a time: +//! this computer's, or a paired computer's through a loopback gateway. +//! +//! A switch connects the target while the current one stays in use and replaces +//! it only once connected. The window then reloads into the new one under a new +//! client instance id, so an App Server still holding the previous id in its +//! reconnect grace never hands back stale state. + +use std::future::Future; +use std::sync::Mutex; +use std::time::Instant; + +use openaide_remote_client::Gateway; +use serde::Serialize; +use tauri::{Emitter, Manager}; + +use crate::DesktopState; +use crate::desktop_app_server_launch::{ + LocalHttpConnection, app_server_endpoint_reachable, launch_app_server_for_desktop, +}; +use crate::desktop_runtime::{DesktopRuntimeEnvironment, write_bootstrap_preferences}; +use crate::wsl_runtime::discover_wsl_distros; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DesktopBootstrap { + client_instance_id: String, + connection: LocalHttpConnection, + platform: &'static str, + runtime_environment: DesktopRuntimeEnvironment, + runtime_options: DesktopRuntimeOptions, + /// Names the paired computer whose App Server the window uses instead of this one's. + #[serde(skip_serializing_if = "Option::is_none")] + remote_computer: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DesktopRuntimeOptions { + wsl_distros: Vec, +} + +#[derive(Clone, Debug, Serialize)] +struct DesktopBootstrapProgress { + message: String, + stage: &'static str, +} + +/// Why a computer could not be reached: a stable class for diagnostics and a +/// sentence for the user. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct ConnectError { + pub(crate) class: &'static str, + pub(crate) message: String, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum SwitchOutcome { + Switched, + AlreadyInUse, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum SwitchError { + Failed(String), + Cancelled, +} + +/// The App Server the window uses. +struct InUse { + connection: LocalHttpConnection, + client_instance_id: String, + /// The paired computer's id; `None` while this computer is in use. + computer: Option, + /// Carries the window's requests to a paired computer; stopped when the + /// window leaves it. + gateway: Option, +} + +/// Connection ownership for the Desktop process. +#[derive(Default)] +pub(crate) struct DesktopConnections { + local: Mutex>, + in_use: Mutex>, + /// One bootstrap or switch at a time, held across the connection attempt. + switching: tokio::sync::Mutex<()>, + /// One start of this computer's App Server at a time. + launching: tokio::sync::Mutex<()>, + /// Ends the switch attempt in progress. + cancel: Mutex>>, +} + +impl DesktopConnections { + /// This computer's App Server, for the native hold and Desktop Update. + pub(crate) fn local(&self) -> Option { + self.local.lock().ok()?.clone() + } + + /// The endpoint and client instance id the window uses now. + pub(crate) fn in_use(&self) -> Option<(LocalHttpConnection, String)> { + let in_use = self.in_use.lock().ok()?; + in_use + .as_ref() + .map(|in_use| (in_use.connection.clone(), in_use.client_instance_id.clone())) + } + + /// The paired computer the window uses, `Some(None)` for this computer, and + /// `None` before the first bootstrap. + pub(crate) fn computer_in_use(&self) -> Option> { + let in_use = self.in_use.lock().ok()?; + in_use.as_ref().map(|in_use| in_use.computer.clone()) + } + + /// Ends the switch attempt in progress; false when there is none. + pub(crate) fn cancel(&self) -> bool { + let Some(cancel) = self.cancel.lock().ok().and_then(|mut slot| slot.take()) else { + return false; + }; + cancel.send(()).is_ok() + } + + fn start_using( + &self, + computer: Option, + connection: LocalHttpConnection, + gateway: Option, + ) -> Option { + if computer.is_none() { + if let Ok(mut local) = self.local.lock() { + *local = Some(connection.clone()); + } + } + let previous = self.in_use.lock().ok()?.replace(InUse { + connection, + client_instance_id: format!("desktop-{}", uuid::Uuid::new_v4()), + computer, + gateway, + }); + previous.and_then(|previous| previous.gateway) + } + + /// Records a new start of this computer's App Server; true when the window + /// uses it and must follow the replacement. + fn replace_local(&self, connection: LocalHttpConnection) -> bool { + if let Ok(mut local) = self.local.lock() { + *local = Some(connection.clone()); + } + let Ok(mut in_use) = self.in_use.lock() else { + return false; + }; + match in_use.as_mut() { + Some(in_use) if in_use.computer.is_none() => { + in_use.connection = connection; + true + } + _ => false, + } + } + + /// Moves the window to `target`, a paired computer's id or this computer for + /// `None`. `connect` reaches the target while the current App Server stays in + /// use; `persist` saves the choice for the next start. Nothing changes unless + /// both succeed, so a failure or a cancel leaves the window where it was. + pub(crate) async fn switch( + &self, + target: Option, + operation_id: &str, + connect: C, + persist: impl FnOnce(&Option) -> Result<(), String>, + ) -> Result + where + C: FnOnce() -> F, + F: Future), ConnectError>>, + { + let started_at = Instant::now(); + let _switching = self.switching.lock().await; + let source = self.computer_in_use(); + if source.as_ref() == Some(&target) { + return Ok(SwitchOutcome::AlreadyInUse); + } + let source_kind = match &source { + None => "none", + Some(None) => "this_computer", + Some(Some(_)) => "paired", + }; + let target_kind = if target.is_some() { + "paired" + } else { + "this_computer" + }; + desktop_log!( + "desktop_computer_switch_started operation_id={operation_id} source_kind={source_kind} target_kind={target_kind}" + ); + let finish = |outcome: &str, error_class: Option<&str>| { + desktop_log!( + "desktop_computer_switch_completed operation_id={operation_id} source_kind={source_kind} target_kind={target_kind} outcome={outcome}{} duration_ms={}", + error_class + .map(|class| format!(" error_class={class}")) + .unwrap_or_default(), + started_at.elapsed().as_millis() + ); + }; + + let (cancel, cancelled) = tokio::sync::oneshot::channel(); + if let Ok(mut slot) = self.cancel.lock() { + *slot = Some(cancel); + } + let connected = tokio::select! { + connected = connect() => Some(connected), + _ = cancelled => None, + }; + if let Ok(mut slot) = self.cancel.lock() { + slot.take(); + } + let (connection, gateway) = match connected { + None => { + finish("cancelled", None); + return Err(SwitchError::Cancelled); + } + Some(Err(error)) => { + finish("failed", Some(error.class)); + return Err(SwitchError::Failed(error.message)); + } + Some(Ok(connected)) => connected, + }; + if let Err(message) = persist(&target) { + if let Some(gateway) = gateway { + gateway.stop().await; + } + finish("failed", Some("preferences_write")); + return Err(SwitchError::Failed(message)); + } + if let Some(previous) = self.start_using(target, connection, gateway) { + previous.stop().await; + } + finish("switched", None); + Ok(SwitchOutcome::Switched) + } +} + +pub(crate) fn emit_bootstrap_progress(app: &tauri::AppHandle, stage: &'static str, message: String) { + let _ = app.emit( + "desktop-bootstrap-progress", + DesktopBootstrapProgress { message, stage }, + ); +} + +/// Reloads the window into the App Server now in use, for a switch the window +/// did not ask for itself. +pub(crate) fn reload_window(app: &tauri::AppHandle) { + if let Some(window) = app.get_webview_window("main") { + if window.reload().is_err() { + desktop_log!("desktop_window_reload_failed error_kind=webview"); + } + } +} + +#[tauri::command] +pub(crate) async fn desktop_bootstrap( + app: tauri::AppHandle, + state: tauri::State<'_, DesktopState>, + connections: tauri::State<'_, DesktopConnections>, +) -> Result { + let (environment, remote) = { + let preferences = state + .preferences + .lock() + .map_err(|_| "desktop bootstrap preferences unavailable")?; + ( + preferences.environment.clone(), + preferences.remote().cloned(), + ) + }; + let runtime_options = DesktopRuntimeOptions { + wsl_distros: discover_wsl_distros(), + }; + let _switching = connections.switching.lock().await; + // A reload, including the one that ends a switch, finds the App Server in use. + if let Some((connection, client_instance_id)) = connections.in_use() { + return Ok(DesktopBootstrap { + client_instance_id, + connection, + platform: std::env::consts::OS, + runtime_environment: environment, + runtime_options, + remote_computer: remote.map(|computer| computer.name), + }); + } + + let operation_id = format!("desktop-bootstrap-{}", uuid::Uuid::new_v4()); + match &remote { + Some(computer) => { + // This computer's App Server starts as well, so its own work, its + // Remote Devices, and switching back never wait for a first visit. + let background = app.clone(); + tauri::async_runtime::spawn(async move { + let operation_id = format!("desktop-local-start-{}", uuid::Uuid::new_v4()); + let _ = ensure_local(&background, None, operation_id).await; + }); + emit_bootstrap_progress(&app, "remote", format!("Connecting to {}", computer.name)); + let (connection, gateway) = crate::desktop_remote::connect(&app, computer) + .await + .map_err(|error| error.message)?; + connections.start_using(Some(computer.id.clone()), connection, Some(gateway)); + } + None => { + let connection = ensure_local(&app, Some(app.clone()), operation_id).await?; + connections.start_using(None, connection, None); + } + } + let mut preferences = state + .preferences + .lock() + .map_err(|_| "desktop bootstrap preferences unavailable")?; + if preferences.previous_environment.take().is_some() { + write_bootstrap_preferences(&state.preferences_path, &preferences)?; + } + drop(preferences); + let (connection, client_instance_id) = connections + .in_use() + .ok_or_else(|| "desktop connection state unavailable".to_string())?; + Ok(DesktopBootstrap { + client_instance_id, + connection, + platform: std::env::consts::OS, + runtime_environment: environment, + runtime_options, + remote_computer: remote.map(|computer| computer.name), + }) +} + +/// This computer's App Server: the running one while it still answers, +/// otherwise a new start. Progress reaches the startup window only when given. +pub(crate) async fn ensure_local( + app: &tauri::AppHandle, + progress: Option, + operation_id: String, +) -> Result { + let connections = app.state::(); + let _launching = connections.launching.lock().await; + let cached = connections.local(); + if let Some(connection) = &cached { + if app_server_endpoint_reachable(connection).await { + return Ok(connection.clone()); + } + } + let started_at = Instant::now(); + desktop_log!( + "desktop_app_server_ensure_started operation_id={operation_id} had_connection={} reason_code={}", + cached.is_some(), + if cached.is_some() { + "endpoint_unreachable" + } else { + "no_connection" + } + ); + let state = app.state::(); + let environment = state + .preferences + .lock() + .map_err(|_| "desktop bootstrap preferences unavailable")? + .environment + .clone(); + let launched = launch_app_server_for_desktop( + state.runtime_paths.clone(), + state.wsl_resource_binary.clone(), + environment, + progress, + operation_id.clone(), + ) + .await; + let connection = match launched { + Ok(connection) => connection, + Err(message) => { + desktop_log!( + "desktop_app_server_ensure_completed operation_id={operation_id} outcome=failure duration_ms={}", + started_at.elapsed().as_millis() + ); + return Err(message); + } + }; + let window_follows = connections.replace_local(connection.clone()); + if window_follows { + let _ = app.emit("desktop-app-server-replaced", &connection); + } + desktop_log!( + "desktop_app_server_ensure_completed operation_id={operation_id} outcome=success window_follows={window_follows} duration_ms={}", + started_at.elapsed().as_millis() + ); + Ok(connection) +} + +/// Restores this computer's App Server after machine sleep or an idle shutdown, +/// whichever computer the window uses. Returns the new endpoint when it started +/// again; the window hears of it through the replacement event while it uses it. +#[tauri::command] +pub(crate) async fn desktop_ensure_app_server( + app: tauri::AppHandle, +) -> Result, String> { + let before = app.state::().local(); + let operation_id = format!("desktop-ensure-app-server-{}", uuid::Uuid::new_v4()); + let connection = ensure_local(&app, None, operation_id).await?; + Ok((before.as_ref() != Some(&connection)).then_some(connection)) +} + +#[cfg(test)] +#[path = "desktop_connection_tests.rs"] +mod tests; diff --git a/apps/desktop/src-tauri/src/desktop_connection_tests.rs b/apps/desktop/src-tauri/src/desktop_connection_tests.rs new file mode 100644 index 00000000..a20ed4a7 --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_connection_tests.rs @@ -0,0 +1,159 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; + +use super::*; + +fn connection(port: u16) -> LocalHttpConnection { + LocalHttpConnection { + kind: "localHttp".to_string(), + endpoint_url: format!("http://127.0.0.1:{port}/probe"), + auth_token: "a".repeat(64), + } +} + +/// The window on this computer's App Server, as after a local start. +fn on_this_computer() -> DesktopConnections { + let connections = DesktopConnections::default(); + connections.start_using(None, connection(1), None); + connections +} + +fn studio() -> Option { + Some("studio-key".to_string()) +} + +fn block_on(future: impl std::future::Future) -> T { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(future) +} + +#[test] +fn a_switch_moves_the_window_with_a_new_client_instance() { + let connections = on_this_computer(); + let (_, before) = connections.in_use().unwrap(); + let persisted = std::sync::Mutex::new(None); + + let outcome = block_on(connections.switch( + studio(), + "op-1", + || async { Ok((connection(2), None)) }, + |target| { + *persisted.lock().unwrap() = Some(target.clone()); + Ok(()) + }, + )); + + assert_eq!(outcome, Ok(SwitchOutcome::Switched)); + let (now, client_instance_id) = connections.in_use().unwrap(); + assert_eq!(now, connection(2)); + assert_ne!(client_instance_id, before); + assert_eq!(connections.computer_in_use(), Some(studio())); + assert_eq!(*persisted.lock().unwrap(), Some(studio())); +} + +#[test] +fn the_computer_in_use_is_not_connected_again() { + let connections = on_this_computer(); + let attempts = AtomicUsize::new(0); + + let outcome = block_on(connections.switch( + None, + "op-1", + || async { + attempts.fetch_add(1, Ordering::Relaxed); + Ok((connection(2), None)) + }, + |_| Ok(()), + )); + + assert_eq!(outcome, Ok(SwitchOutcome::AlreadyInUse)); + assert_eq!(attempts.load(Ordering::Relaxed), 0); +} + +#[test] +fn a_failed_connection_leaves_the_window_where_it_was() { + let connections = on_this_computer(); + let before = connections.in_use(); + + let outcome = block_on(connections.switch( + studio(), + "op-1", + || async { + Err(ConnectError { + class: "unreachable", + message: "Studio is unreachable.".to_string(), + }) + }, + |_| panic!("nothing is saved for a failed switch"), + )); + + assert_eq!( + outcome, + Err(SwitchError::Failed("Studio is unreachable.".to_string())) + ); + assert_eq!(connections.in_use(), before); + assert_eq!(connections.computer_in_use(), Some(None)); +} + +#[test] +fn a_choice_that_cannot_be_saved_leaves_the_window_where_it_was() { + let connections = on_this_computer(); + let before = connections.in_use(); + + let outcome = block_on(connections.switch( + studio(), + "op-1", + || async { Ok((connection(2), None)) }, + |_| Err("The choice could not be saved.".to_string()), + )); + + assert_eq!( + outcome, + Err(SwitchError::Failed("The choice could not be saved.".to_string())) + ); + assert_eq!(connections.in_use(), before); +} + +#[test] +fn cancelling_ends_the_attempt_and_keeps_the_computer_in_use() { + let connections = on_this_computer(); + let before = connections.in_use(); + + let outcome = block_on(async { + let switching = connections.switch( + studio(), + "op-1", + || std::future::pending(), + |_| panic!("nothing is saved for a cancelled switch"), + ); + let cancelling = async { + // The attempt registers itself before it waits on the target. + while !connections.cancel() { + tokio::task::yield_now().await; + } + }; + let (outcome, ()) = tokio::join!(switching, cancelling); + outcome + }); + + assert_eq!(outcome, Err(SwitchError::Cancelled)); + assert_eq!(connections.in_use(), before); + // Nothing is left to cancel once the attempt has ended. + assert!(!connections.cancel()); +} + +#[test] +fn only_this_computers_app_server_replacement_reaches_the_window() { + let connections = on_this_computer(); + assert!(connections.replace_local(connection(3))); + assert_eq!(connections.in_use().unwrap().0, connection(3)); + assert_eq!(connections.local(), Some(connection(3))); + + let remote = DesktopConnections::default(); + remote.start_using(studio(), connection(2), None); + assert!(!remote.replace_local(connection(4))); + assert_eq!(remote.in_use().unwrap().0, connection(2)); + assert_eq!(remote.local(), Some(connection(4))); +} diff --git a/apps/desktop/src-tauri/src/desktop_local_attention.rs b/apps/desktop/src-tauri/src/desktop_local_attention.rs new file mode 100644 index 00000000..784586dc --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_local_attention.rs @@ -0,0 +1,277 @@ +//! Watches this computer's own Tasks for attention while the window uses a +//! paired computer's App Server (ADR-0070 Attention). +//! +//! The window's notifications follow the App Server it renders, so without this +//! a Task here that needs the user would go unnoticed while another computer is +//! in use. A native thread holds `taskAttention/wait` on this computer's App +//! Server, which the Desktop keeps running, and hands each changed list to the +//! window; the window's notification manager applies the usual baseline, +//! in-sight, and one-per-Task rules. The watch runs only while a paired computer +//! is in use: with this computer in use the window's own replica already covers +//! it. It initializes as a shell-hold client, so it never blocks a Desktop Update. + +use std::sync::Mutex; +use std::thread; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use tauri::{Emitter, Manager}; + +use crate::desktop_app_server_hold::{HoldError, HoldTarget, post_loopback}; +use crate::desktop_connection::DesktopConnections; + +/// The App Server holds a wait for up to 25 s; the read allows for that and a slow answer. +const WAIT_TIMEOUT: Duration = Duration::from_secs(40); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); +/// How often an idle watch looks whether a paired computer came into use. +const IDLE_INTERVAL: Duration = Duration::from_secs(2); +const RETRY_DELAYS: [Duration; 5] = [ + Duration::from_secs(1), + Duration::from_secs(2), + Duration::from_secs(5), + Duration::from_secs(15), + Duration::from_secs(30), +]; +pub(crate) const ATTENTION_EVENT: &str = "desktop-local-attention"; + +/// The latest attention list, kept for a window that reloads mid-watch. +#[derive(Default)] +pub(crate) struct LocalAttention { + latest: Mutex>, +} + +/// One answer of the watch loop. +#[derive(Debug, PartialEq)] +pub(crate) enum Step { + /// No paired computer is in use; nothing to watch. + Idle, + /// The attention list changed (or is the first one); the window should know. + Changed(Value), + /// The held wait ended with nothing new. + Unchanged, + /// A request failed; try again after the delay. + Retry(Duration), +} + +/// The watch's state between requests, kept apart from Tauri so it can be tested. +pub(crate) struct Watch { + client_instance_id: String, + target: Option, + initialized: bool, + cursor: Option, + failures: usize, + next_request: u64, + started: Option, +} + +impl Watch { + pub(crate) fn new() -> Self { + Self { + client_instance_id: format!("desktop-attention-{}", uuid::Uuid::new_v4()), + target: None, + initialized: false, + cursor: None, + failures: 0, + next_request: 0, + started: None, + } + } + + /// Advances the watch for `target` (this computer's App Server while a paired + /// computer is in use, else `None`) with `post` as the transport. + pub(crate) fn step( + &mut self, + target: Option, + post: &mut impl FnMut( + &HoldTarget, + &str, + &str, + &str, + Value, + Duration, + ) -> Result, + ) -> Step { + let Some(target) = target else { + if let Some(started) = self.started.take() { + desktop_log!( + "desktop_local_attention_stopped reason=this_computer_in_use duration_ms={}", + started.elapsed().as_millis() + ); + } + self.reset(None); + return Step::Idle; + }; + if self.target.as_ref() != Some(&target) { + let replaced = self.target.is_some(); + self.reset(Some(target.clone())); + if self.started.is_none() { + self.started = Some(Instant::now()); + } + desktop_log!("desktop_local_attention_started replaced={replaced}"); + } + if !self.initialized { + let params = json!({ + "clientInstanceId": self.client_instance_id, + "shell": { "kind": "desktop", "name": "OpenAIDE", "version": env!("CARGO_PKG_VERSION") }, + "requestedSurface": { "kind": "home" }, + "workspaceRoots": [], + "role": "shellHold", + }); + match self.post(&target, "client/initialize", params, REQUEST_TIMEOUT, post) { + Ok(_) => self.initialized = true, + Err(error) => return self.failed("initialize", &error), + } + } + let params = match &self.cursor { + Some(cursor) => json!({ "cursor": cursor }), + None => json!({}), + }; + match self.post(&target, "taskAttention/wait", params, WAIT_TIMEOUT, post) { + Ok(result) => { + if self.failures > 0 { + desktop_log!( + "desktop_local_attention_recovered attempt_count={}", + self.failures + ); + } + self.failures = 0; + let cursor = result["cursor"].as_str().map(str::to_string); + let first = self.cursor.is_none(); + let changed = first || cursor != self.cursor; + self.cursor = cursor; + let tasks = result.get("tasks").cloned().unwrap_or_else(|| json!([])); + if !changed { + return Step::Unchanged; + } + let count = tasks.as_array().map_or(0, Vec::len); + desktop_log!("desktop_local_attention_changed tasks={count} first={first}"); + Step::Changed(tasks) + } + Err(error) => { + // A refusal is most often a client the App Server forgot; start over. + if error == HoldError::Refused { + self.initialized = false; + self.cursor = None; + } + self.failed("wait", &error) + } + } + } + + fn post( + &mut self, + target: &HoldTarget, + method: &str, + params: Value, + timeout: Duration, + post: &mut impl FnMut( + &HoldTarget, + &str, + &str, + &str, + Value, + Duration, + ) -> Result, + ) -> Result { + self.next_request += 1; + let id = format!("desktop-attention-{}", self.next_request); + post( + target, + &self.client_instance_id, + &id, + method, + params, + timeout, + ) + } + + fn failed(&mut self, stage: &str, error: &HoldError) -> Step { + let delay = RETRY_DELAYS[self.failures.min(RETRY_DELAYS.len() - 1)]; + self.failures += 1; + desktop_log!( + "desktop_local_attention_failed stage={stage} error_class={} attempt_count={} retry_ms={}", + error.class(), + self.failures, + delay.as_millis() + ); + Step::Retry(delay) + } + + fn reset(&mut self, target: Option) { + self.target = target; + self.initialized = false; + self.cursor = None; + self.failures = 0; + } +} + +/// Starts the watch thread for the rest of the process. +pub(crate) fn install(app: &tauri::App) { + app.manage(LocalAttention::default()); + let handle = app.handle().clone(); + let spawned = thread::Builder::new() + .name("openaide-local-attention".to_string()) + .spawn(move || { + let mut watch = Watch::new(); + let mut post = post_loopback; + loop { + let target = watched_target(&handle); + match watch.step(target, &mut post) { + Step::Idle => { + forget(&handle); + thread::sleep(IDLE_INTERVAL); + } + Step::Changed(tasks) => publish(&handle, tasks), + Step::Unchanged => {} + Step::Retry(delay) => thread::sleep(delay), + } + } + }); + if spawned.is_err() { + desktop_log!("desktop_local_attention_failed stage=start error_class=thread"); + } +} + +/// This computer's App Server, but only while the window uses a paired computer's. +fn watched_target(app: &tauri::AppHandle) -> Option { + let connections = app.state::(); + let paired_in_use = matches!(connections.computer_in_use(), Some(Some(_))); + paired_in_use + .then(|| connections.local()) + .flatten() + .map(|local| HoldTarget { + endpoint_url: local.endpoint_url, + auth_token: local.auth_token, + }) +} + +fn publish(app: &tauri::AppHandle, tasks: Value) { + if let Ok(mut latest) = app.state::().latest.lock() { + *latest = Some(tasks.clone()); + } + if app + .emit(ATTENTION_EVENT, json!({ "tasks": tasks })) + .is_err() + { + desktop_log!("desktop_local_attention_failed stage=emit error_class=window"); + } +} + +fn forget(app: &tauri::AppHandle) { + if let Ok(mut latest) = app.state::().latest.lock() { + *latest = None; + } +} + +/// The latest list for a window that starts while a paired computer is in use; +/// `None` while nothing is watched yet. +#[tauri::command] +pub(crate) fn desktop_local_attention_snapshot( + state: tauri::State<'_, LocalAttention>, +) -> Option { + state.latest.lock().ok()?.clone() +} + +#[cfg(test)] +#[path = "desktop_local_attention_tests.rs"] +mod tests; diff --git a/apps/desktop/src-tauri/src/desktop_local_attention_tests.rs b/apps/desktop/src-tauri/src/desktop_local_attention_tests.rs new file mode 100644 index 00000000..1a12b43f --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_local_attention_tests.rs @@ -0,0 +1,146 @@ +use std::cell::RefCell; + +use super::*; + +fn this_computer() -> HoldTarget { + HoldTarget { + endpoint_url: "http://127.0.0.1:1/probe".to_string(), + auth_token: "a".repeat(64), + } +} + +/// A fake App Server: records the methods and params asked, answers from a script. +struct Server { + asked: RefCell>, + answers: RefCell>>, +} + +impl Server { + fn new(answers: Vec>) -> Self { + Self { + asked: RefCell::new(Vec::new()), + answers: RefCell::new(answers.into_iter().rev().collect()), + } + } + + fn post( + &self, + ) -> impl FnMut(&HoldTarget, &str, &str, &str, Value, Duration) -> Result + '_ + { + move |_, _, _, method, params, _| { + self.asked.borrow_mut().push((method.to_string(), params)); + self.answers.borrow_mut().pop().expect("no scripted answer") + } + } + + fn methods(&self) -> Vec { + self.asked + .borrow() + .iter() + .map(|(method, _)| method.clone()) + .collect() + } +} + +fn attention(cursor: &str, tasks: Value) -> Result { + Ok(json!({ "cursor": cursor, "tasks": tasks })) +} + +#[test] +fn watches_nothing_while_this_computer_is_in_use() { + let server = Server::new(vec![]); + let mut watch = Watch::new(); + assert_eq!(watch.step(None, &mut server.post()), Step::Idle); + assert!(server.methods().is_empty()); +} + +#[test] +fn joins_as_a_shell_hold_and_hands_over_the_first_list_then_only_changes() { + let server = Server::new(vec![ + Ok(json!({})), + attention("c1", json!([{ "task_id": "t1" }])), + attention("c1", json!([{ "task_id": "t1" }])), + attention("c2", json!([])), + ]); + let mut watch = Watch::new(); + let mut post = server.post(); + assert_eq!( + watch.step(Some(this_computer()), &mut post), + Step::Changed(json!([{ "task_id": "t1" }])) + ); + assert_eq!( + watch.step(Some(this_computer()), &mut post), + Step::Unchanged + ); + assert_eq!( + watch.step(Some(this_computer()), &mut post), + Step::Changed(json!([])) + ); + drop(post); + let asked = server.asked.borrow(); + assert_eq!(asked[0].0, "client/initialize"); + assert_eq!(asked[0].1["role"], "shellHold"); + assert_eq!(asked[1].1, json!({})); + assert_eq!(asked[2].1, json!({ "cursor": "c1" })); + assert_eq!(asked[3].1, json!({ "cursor": "c1" })); +} + +#[test] +fn starts_over_when_the_app_server_forgot_it_and_backs_off_on_failures() { + let server = Server::new(vec![ + Ok(json!({})), + Err(HoldError::Refused), + Err(HoldError::Unreachable), + Ok(json!({})), + attention("c1", json!([])), + ]); + let mut watch = Watch::new(); + let mut post = server.post(); + assert_eq!( + watch.step(Some(this_computer()), &mut post), + // timing: data — the backoff is compared as a value, never waited on. + Step::Retry(Duration::from_secs(1)) + ); + assert_eq!( + watch.step(Some(this_computer()), &mut post), + // timing: data — the backoff is compared as a value, never waited on. + Step::Retry(Duration::from_secs(2)) + ); + assert_eq!( + watch.step(Some(this_computer()), &mut post), + Step::Changed(json!([])) + ); + drop(post); + assert_eq!( + server.methods(), + [ + "client/initialize", + "taskAttention/wait", + "client/initialize", + "client/initialize", + "taskAttention/wait" + ] + ); +} + +#[test] +fn starts_over_after_this_computer_is_used_again() { + let server = Server::new(vec![ + Ok(json!({})), + attention("c1", json!([])), + Ok(json!({})), + attention("c1", json!([])), + ]); + let mut watch = Watch::new(); + let mut post = server.post(); + assert_eq!( + watch.step(Some(this_computer()), &mut post), + Step::Changed(json!([])) + ); + assert_eq!(watch.step(None, &mut post), Step::Idle); + // A new watch hands over its first list again, so a reloaded window gets a baseline. + assert_eq!( + watch.step(Some(this_computer()), &mut post), + Step::Changed(json!([])) + ); +} diff --git a/apps/desktop/src-tauri/src/desktop_remote.rs b/apps/desktop/src-tauri/src/desktop_remote.rs index 171c667c..ffad0629 100644 --- a/apps/desktop/src-tauri/src/desktop_remote.rs +++ b/apps/desktop/src-tauri/src/desktop_remote.rs @@ -1,9 +1,10 @@ //! Desktop as a Remote Device of an App Server on another computer (ADR-0063). //! -//! The shell pairs by Pairing Code and then starts no App Server of its own: -//! the WebView talks to a loopback gateway that carries its requests to the -//! paired computer. Which computer is in use is a bootstrap preference, so a -//! change takes effect through the same restart as a runtime environment change. +//! The shell pairs by Pairing Code. While a paired computer is in use, the +//! WebView talks to a loopback gateway that carries its requests there, and this +//! computer's App Server keeps running (ADR-0070). A choice takes effect in place +//! through [`crate::desktop_connection`]; it is saved for the next start once the +//! computer answered. use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -15,8 +16,12 @@ use serde::{Deserialize, Serialize}; use tauri::Manager; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; +use crate::DesktopState; +use crate::desktop_app_server_launch::LocalHttpConnection; +use crate::desktop_connection::{ + ConnectError, DesktopConnections, SwitchError, SwitchOutcome, ensure_local, reload_window, +}; use crate::desktop_runtime::{DesktopBootstrapPreferences, write_bootstrap_preferences}; -use crate::{DesktopState, LocalHttpConnection}; /// A computer whose App Server trusts this Desktop: its public key and the name /// it gave itself. Neither is a credential; the device key is stored apart. @@ -35,14 +40,20 @@ impl DesktopBootstrapPreferences { .find(|computer| &computer.id == id) } - /// Remembers a computer, replacing an earlier pairing with it, and selects it. - fn pair(&mut self, computer: RemoteComputer) { + /// Remembers a computer, replacing an earlier pairing with it. Using it is a + /// switch of its own, saved once the computer answers. + fn remember(&mut self, computer: RemoteComputer) { self.remote_computers .retain(|known| known.id != computer.id); - self.remote_computer = Some(computer.id.clone()); self.remote_computers.push(computer); } + fn paired(&self, id: &str) -> Option<&RemoteComputer> { + self.remote_computers + .iter() + .find(|computer| computer.id == id) + } + /// Forgets a computer; true when it was the one in use. fn forget(&mut self, id: &str) -> bool { self.remote_computers.retain(|known| known.id != id); @@ -67,7 +78,6 @@ pub(crate) struct RemoteComputerSummary { pub(crate) struct DesktopRemoteState { endpoint: tokio::sync::Mutex>, key_path: PathBuf, - failure_reported: AtomicBool, } impl DesktopRemoteState { @@ -75,7 +85,6 @@ impl DesktopRemoteState { Self { endpoint: tokio::sync::Mutex::new(None), key_path: app_local_data.join("device.key"), - failure_reported: AtomicBool::new(false), } } @@ -100,33 +109,124 @@ impl DesktopRemoteState { } } -/// Opens the gateway to the selected computer. The computer is reached first, so +/// Opens a gateway to `computer`. The computer is reached first, so a switch or /// startup can name the problem instead of showing an empty workspace. pub(crate) async fn connect( app: &tauri::AppHandle, computer: &RemoteComputer, -) -> Result { - let server = computer - .id - .parse::() - .map_err(|_| "The saved pairing is damaged. Pair with the computer again.".to_string())?; - let endpoint = app.state::().endpoint().await?; +) -> Result<(LocalHttpConnection, remote::Gateway), ConnectError> { + let server = computer.id.parse::().map_err(|_| ConnectError { + class: "pairing_damaged", + message: "The saved pairing is damaged. Pair with the computer again.".to_string(), + })?; + let this_computer = |message: String| ConnectError { + class: "endpoint", + message, + }; + let endpoint = app + .state::() + .endpoint() + .await + .map_err(this_computer)?; let client = Arc::new(remote::RemoteClient::new(endpoint, server, logger())); client .connection() .await - .map_err(|failure| message(failure, &computer.name))?; + .map_err(|failure| ConnectError { + class: failure.class(), + message: message(failure, &computer.name), + })?; let reporter = app.clone(); + let name = computer.name.clone(); + // Each connection says once that retrying cannot help, so a later switch to + // the same or another computer can be told again. + let reported = AtomicBool::new(false); let gateway = remote::Gateway::start(client, logger(), move |failure| { - report_failure(&reporter, failure) + if !reported.swap(true, Ordering::AcqRel) { + report_failure(&reporter, &name, failure); + } }) .await - .map_err(|_| "OpenAIDE could not start its connection on this computer.".to_string())?; - Ok(LocalHttpConnection { + .map_err(|_| this_computer("OpenAIDE could not start its connection on this computer.".to_string()))?; + let connection = LocalHttpConnection { kind: "localHttp".to_string(), endpoint_url: format!("http://{}/probe", gateway.address), - auth_token: gateway.token, - }) + auth_token: gateway.token.clone(), + }; + Ok((connection, gateway)) +} + +/// Moves the window to a paired computer, or to this computer for `None`, once +/// the target answers. Saving the choice is the last step, so a failure changes +/// nothing. +pub(crate) async fn switch( + app: &tauri::AppHandle, + target: Option, + operation_id: &str, +) -> Result { + let state = app.state::(); + let computer = match &target { + Some(id) => Some( + state + .preferences + .lock() + .map_err(|_| SwitchError::Failed("Desktop preferences are unavailable.".to_string()))? + .paired(id) + .cloned() + .ok_or_else(|| SwitchError::Failed("This computer is no longer paired.".to_string()))?, + ), + None => None, + }; + let connections = app.state::(); + connections + .switch( + target, + operation_id, + || async { + match &computer { + Some(computer) => connect(app, computer) + .await + .map(|(connection, gateway)| (connection, Some(gateway))), + None => ensure_local(app, None, operation_id.to_string()) + .await + .map(|connection| (connection, None)) + .map_err(|message| ConnectError { + class: "app_server_start", + message, + }), + } + }, + |target| { + let mut preferences = state + .preferences + .lock() + .map_err(|_| "Desktop preferences are unavailable.".to_string())?; + preferences.remote_computer = target.clone(); + write_bootstrap_preferences(&state.preferences_path, &preferences) + }, + ) + .await +} + +/// What a switch the window asked for came to; the window reloads after `switched`. +fn outcome_name(result: Result) -> Result<&'static str, String> { + match result { + Ok(SwitchOutcome::Switched) => Ok("switched"), + Ok(SwitchOutcome::AlreadyInUse) => Ok("alreadyInUse"), + Err(SwitchError::Cancelled) => Ok("cancelled"), + Err(SwitchError::Failed(message)) => Err(message), + } +} + +/// A correlation id from the window, used in logs only when it is plainly safe. +fn operation_id_or_new(candidate: Option) -> String { + candidate + .filter(|id| { + !id.is_empty() + && id.len() <= 80 + && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + }) + .unwrap_or_else(|| format!("desktop-computer-switch-{}", uuid::Uuid::new_v4())) } #[tauri::command] @@ -148,14 +248,15 @@ pub(crate) fn desktop_remote_snapshot( .collect()) } -/// Spends the invite another computer shows, then restarts into its workspace. +/// Spends the invite another computer shows, then switches to that computer. #[tauri::command] pub(crate) async fn desktop_remote_pair( app: tauri::AppHandle, state: tauri::State<'_, DesktopState>, remote_state: tauri::State<'_, DesktopRemoteState>, code: String, -) -> Result<(), String> { + operation_id: Option, +) -> Result<&'static str, String> { let invite = remote::InviteCode::decode(&code).map_err(|error| { match error { remote::PairingCodeError::WrongKind => { @@ -182,79 +283,76 @@ pub(crate) async fn desktop_remote_pair( } other => message(other, "The other computer"), })?; - let mut preferences = state - .preferences - .lock() - .map_err(|_| "desktop bootstrap preferences unavailable")?; - preferences.pair(RemoteComputer { - id: paired.id.to_string(), - name: if paired.name.is_empty() { - "Computer".to_string() - } else { - paired.name - }, - }); - write_bootstrap_preferences(&state.preferences_path, &preferences)?; - drop(preferences); - desktop_log!("desktop_remote_computer_selected reason=paired"); - crate::restart_desktop(&app); - Ok(()) + let id = paired.id.to_string(); + { + let mut preferences = state + .preferences + .lock() + .map_err(|_| "desktop bootstrap preferences unavailable")?; + preferences.remember(RemoteComputer { + id: id.clone(), + name: if paired.name.is_empty() { + "Computer".to_string() + } else { + paired.name + }, + }); + write_bootstrap_preferences(&state.preferences_path, &preferences)?; + } + desktop_log!("desktop_remote_computer_paired"); + outcome_name(switch(&app, Some(id), &operation_id_or_new(operation_id)).await) } -/// Chooses a paired computer, or this computer when `id` is absent. +/// Switches the window to a paired computer, or to this computer when `id` is +/// absent: `switched` (the window reloads), `alreadyInUse`, or `cancelled`. A +/// computer that cannot be reached is an error and changes nothing. #[tauri::command] -pub(crate) fn desktop_remote_use( +pub(crate) async fn desktop_remote_use( app: tauri::AppHandle, - state: tauri::State<'_, DesktopState>, id: Option, -) -> Result<(), String> { - let mut preferences = state - .preferences - .lock() - .map_err(|_| "desktop bootstrap preferences unavailable")?; - if let Some(id) = &id { - if !preferences - .remote_computers - .iter() - .any(|computer| &computer.id == id) - { - return Err("This computer is no longer paired.".to_string()); - } - } - if preferences.remote_computer == id { - return Ok(()); - } - desktop_log!( - "desktop_remote_computer_selected reason={}", - if id.is_some() { "chosen" } else { "local" } - ); - preferences.remote_computer = id; - write_bootstrap_preferences(&state.preferences_path, &preferences)?; - drop(preferences); - crate::restart_desktop(&app); - Ok(()) + operation_id: Option, +) -> Result<&'static str, String> { + outcome_name(switch(&app, id, &operation_id_or_new(operation_id)).await) +} + +/// Ends the switch attempt in progress; the window stays where it is. +#[tauri::command] +pub(crate) fn desktop_remote_cancel(connections: tauri::State<'_, DesktopConnections>) -> bool { + let cancelled = connections.cancel(); + desktop_log!("desktop_computer_switch_cancel_requested pending={cancelled}"); + cancelled } -/// Forgets a pairing on this side. The other computer keeps listing this one -/// until it is removed there; its key alone opens nothing here. +/// Forgets a pairing on this side. Forgetting the computer in use first moves +/// the window to this computer. The other computer keeps listing this one until +/// it is removed there; its key alone opens nothing here. #[tauri::command] -pub(crate) fn desktop_remote_forget( +pub(crate) async fn desktop_remote_forget( app: tauri::AppHandle, state: tauri::State<'_, DesktopState>, + connections: tauri::State<'_, DesktopConnections>, id: String, -) -> Result<(), String> { - let mut preferences = state - .preferences - .lock() - .map_err(|_| "desktop bootstrap preferences unavailable")?; - let was_current = preferences.forget(&id); - write_bootstrap_preferences(&state.preferences_path, &preferences)?; - drop(preferences); - desktop_log!("desktop_remote_computer_forgotten was_current={was_current}"); - if was_current { - crate::restart_desktop(&app); + operation_id: Option, +) -> Result<&'static str, String> { + let in_use = connections.computer_in_use() == Some(Some(id.clone())); + let outcome = if in_use { + outcome_name(switch(&app, None, &operation_id_or_new(operation_id)).await)? + } else { + "alreadyInUse" + }; + if outcome == "cancelled" { + return Ok(outcome); + } + { + let mut preferences = state + .preferences + .lock() + .map_err(|_| "desktop bootstrap preferences unavailable")?; + preferences.forget(&id); + write_bootstrap_preferences(&state.preferences_path, &preferences)?; } - Ok(()) + desktop_log!("desktop_remote_computer_forgotten was_in_use={in_use}"); + Ok(outcome) } /// Returns to this computer's own App Server on the next start. @@ -271,38 +369,30 @@ pub(crate) fn use_this_computer(state: &DesktopState) -> Result { Ok(true) } -/// Says once per run that retrying cannot help, and offers the way back. -fn report_failure(app: &tauri::AppHandle, failure: remote::Failure) { - if app - .state::() - .failure_reported - .swap(true, Ordering::AcqRel) - { - return; - } +/// Says that retrying cannot help, and offers the way back to this computer. +fn report_failure(app: &tauri::AppHandle, name: &str, failure: remote::Failure) { desktop_log!( "desktop_remote_failure_reported error_class={}", failure.class() ); - let name = app - .state::() - .preferences - .lock() - .ok() - .and_then(|preferences| preferences.remote().map(|computer| computer.name.clone())) - .unwrap_or_else(|| "The other computer".to_string()); let handle = app.clone(); app.dialog() - .message(message(failure, &name)) + .message(message(failure, name)) .title("OpenAIDE") .buttons(MessageDialogButtons::OkCancelCustom( "Use this computer".to_string(), "Keep trying".to_string(), )) .show(move |use_local| { - if use_local && use_this_computer(&handle.state::()).is_ok() { - crate::restart_desktop(&handle); + if !use_local { + return; } + tauri::async_runtime::spawn(async move { + let operation_id = operation_id_or_new(None); + if switch(&handle, None, &operation_id).await == Ok(SwitchOutcome::Switched) { + reload_window(&handle); + } + }); }); } diff --git a/apps/desktop/src-tauri/src/desktop_remote_tests.rs b/apps/desktop/src-tauri/src/desktop_remote_tests.rs index 55433874..00e83f88 100644 --- a/apps/desktop/src-tauri/src/desktop_remote_tests.rs +++ b/apps/desktop/src-tauri/src/desktop_remote_tests.rs @@ -8,21 +8,24 @@ fn computer(id: &str, name: &str) -> RemoteComputer { } #[test] -fn pairing_selects_the_computer_and_replaces_an_earlier_pairing_with_it() { +fn pairing_remembers_the_computer_and_replaces_an_earlier_pairing_with_it() { let mut preferences = DesktopBootstrapPreferences::default(); - preferences.pair(computer("a", "Studio")); - preferences.pair(computer("b", "Laptop")); - preferences.pair(computer("a", "Studio again")); + preferences.remember(computer("a", "Studio")); + preferences.remember(computer("b", "Laptop")); + preferences.remember(computer("a", "Studio again")); assert_eq!(preferences.remote_computers.len(), 2); - assert_eq!(preferences.remote(), Some(&computer("a", "Studio again"))); + assert_eq!(preferences.paired("a"), Some(&computer("a", "Studio again"))); + // Using it is a switch of its own, saved once the computer answers. + assert_eq!(preferences.remote(), None); } #[test] fn forgetting_the_computer_in_use_returns_to_this_computer() { let mut preferences = DesktopBootstrapPreferences::default(); - preferences.pair(computer("a", "Studio")); - preferences.pair(computer("b", "Laptop")); + preferences.remember(computer("a", "Studio")); + preferences.remember(computer("b", "Laptop")); + preferences.remote_computer = Some("b".to_string()); assert!(!preferences.forget("a")); assert_eq!(preferences.remote(), Some(&computer("b", "Laptop"))); diff --git a/apps/desktop/src-tauri/src/desktop_support_export.rs b/apps/desktop/src-tauri/src/desktop_support_export.rs index 3c7a406a..afc99d77 100644 --- a/apps/desktop/src-tauri/src/desktop_support_export.rs +++ b/apps/desktop/src-tauri/src/desktop_support_export.rs @@ -1,6 +1,7 @@ use std::path::Path; use std::time::{Duration, Instant}; +use tauri::Manager; use tauri_plugin_dialog::DialogExt; use url::Url; @@ -85,15 +86,14 @@ pub(crate) async fn desktop_save_support_export( .map_err(|_| "Desktop preferences are unavailable.".to_string())? .support_export_directory .clone(); - let connection = state - .connection - .lock() - .map_err(|_| "Desktop runtime state is unavailable.".to_string())? - .clone() + // The export belongs to the App Server the window uses, and to its client. + let (connection, client_instance_id) = app + .state::() + .in_use() .ok_or_else(|| "Desktop runtime is not connected.".to_string())?; let url = support_export_download_url( &connection.endpoint_url, - &state.client_instance_id, + &client_instance_id, &file_handle_id, )?; let operation_id = format!("desktop-support-export-{}", uuid::Uuid::new_v4()); diff --git a/apps/desktop/src-tauri/src/desktop_update.rs b/apps/desktop/src-tauri/src/desktop_update.rs index 5621af01..773c4449 100644 --- a/apps/desktop/src-tauri/src/desktop_update.rs +++ b/apps/desktop/src-tauri/src/desktop_update.rs @@ -513,7 +513,6 @@ pub(crate) fn desktop_cancel_update_download( pub(crate) async fn desktop_install_update( app: AppHandle, state: tauri::State<'_, DesktopUpdateState>, - desktop_state: tauri::State<'_, crate::DesktopState>, ) -> Result { let (candidate, bytes) = { let runtime = state @@ -551,15 +550,16 @@ pub(crate) async fn desktop_install_update( runtime.snapshot.kind = DesktopUpdateKind::Applying; runtime.snapshot.error = None; })?; - // A paired computer's App Server is not this Desktop's to stop: the update - // replaces only the shell, and the gateway ends with this process. - if desktop_state.owns_app_server() { - let endpoint_url = desktop_state.app_server_endpoint_url().map_err(|error| { - let _ = state.fail(&app, DesktopUpdateError::ShutdownFailed); - error - })?; - // The window's client detached before this command; ending the native - // hold is what lets the App Server stop. + // This computer's App Server runs whichever computer the window uses, and + // the update replaces it. A paired computer's App Server is not this + // Desktop's to stop; its gateway ends with this process. + if let Some(local) = app + .state::() + .local() + { + let endpoint_url = local.endpoint_url; + // The window's client detached before this command, or never used this + // App Server; ending the native hold is what lets it stop. let hold = app .state::() .inner() diff --git a/apps/desktop/src-tauri/src/macos_menu.rs b/apps/desktop/src-tauri/src/macos_menu.rs new file mode 100644 index 00000000..e5f29fa7 --- /dev/null +++ b/apps/desktop/src-tauri/src/macos_menu.rs @@ -0,0 +1,51 @@ +use tauri::menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder}; + +/// Installs the native menu. Every item id is a `desktop-command` the shared +/// Frontend handles, so the WebView shortcuts on other platforms stay the same set. +pub(crate) fn install(app: &mut tauri::App) -> tauri::Result<()> { + let new_task = MenuItemBuilder::with_id("new-task", "New Task") + .accelerator("CmdOrCtrl+N") + .build(app)?; + let open_project = MenuItemBuilder::with_id("open-project", "Add Project Folder…") + .accelerator("CmdOrCtrl+O") + .build(app)?; + let settings = MenuItemBuilder::with_id("settings", "Settings…") + .accelerator("CmdOrCtrl+,") + .build(app)?; + let switch_computer = MenuItemBuilder::with_id("switch-computer", "Switch Computer…") + .accelerator("CmdOrCtrl+Shift+S") + .build(app)?; + let check_for_updates = + MenuItemBuilder::with_id("check-for-updates", "Check for Updates…").build(app)?; + let quit = MenuItemBuilder::with_id("quit", "Quit OpenAIDE") + .accelerator("CmdOrCtrl+Q") + .build(app)?; + let application = SubmenuBuilder::new(app, "OpenAIDE") + .items(&[ + &settings, + &check_for_updates, + &PredefinedMenuItem::separator(app)?, + &quit, + ]) + .build()?; + let file = SubmenuBuilder::new(app, "File") + .items(&[&new_task, &open_project, &PredefinedMenuItem::separator(app)?, &switch_computer]) + .build()?; + let edit = SubmenuBuilder::new(app, "Edit") + .items(&[ + &PredefinedMenuItem::undo(app, None)?, + &PredefinedMenuItem::redo(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::cut(app, None)?, + &PredefinedMenuItem::copy(app, None)?, + &PredefinedMenuItem::paste(app, None)?, + &PredefinedMenuItem::select_all(app, None)?, + ]) + .build()?; + app.set_menu( + MenuBuilder::new(app) + .items(&[&application, &file, &edit]) + .build()?, + )?; + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index f036ba7d..28ddb5c5 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,33 +1,29 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use std::io::{BufRead, BufReader, Read}; -#[cfg(target_os = "windows")] -use std::os::windows::process::CommandExt; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; +use std::path::PathBuf; use std::sync::{ Mutex, atomic::{AtomicBool, Ordering}, }; +#[cfg(any(target_os = "macos", target_os = "windows"))] use std::thread; -use std::time::{Duration, Instant}; +#[cfg(any(target_os = "macos", target_os = "windows"))] +use std::time::Duration; -use serde::{Deserialize, Serialize}; -#[cfg(target_os = "macos")] -use tauri::menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder}; +use serde::Serialize; use tauri::{Emitter, Manager}; // First, so every module below can use `desktop_log!`. #[macro_use] mod desktop_log; mod desktop_app_server_hold; +mod desktop_app_server_launch; +mod desktop_connection; +mod desktop_local_attention; mod desktop_remote; mod desktop_renderer_protocol; mod desktop_runtime; mod desktop_secrets; -#[cfg(test)] -#[path = "desktop_server_recovery_tests.rs"] -mod desktop_server_recovery_tests; mod desktop_support_export; mod desktop_update; mod desktop_update_receipt; @@ -37,6 +33,8 @@ mod desktop_update_shutdown; mod desktop_update_tests; mod desktop_webview_log; #[cfg(target_os = "macos")] +mod macos_menu; +#[cfg(target_os = "macos")] mod macos_shell_path; #[cfg(target_os = "macos")] mod macos_webview_resize; @@ -49,42 +47,11 @@ use desktop_runtime::{ write_bootstrap_preferences, }; use desktop_update::DesktopUpdateState; -use startup_process::StartupChild; -use wsl_runtime::{discover_wsl_distros, launch_wsl_app_server_handoff, translate_path_with_wsl}; +use wsl_runtime::{discover_wsl_distros, translate_path_with_wsl}; -const HANDOFF_TIMEOUT: Duration = Duration::from_secs(60); -const MAX_HANDOFF_LINE_BYTES: usize = 8 * 1024; -const MIN_AUTH_TOKEN_BYTES: usize = 32; #[cfg(target_os = "windows")] const CREATE_NO_WINDOW: u32 = 0x0800_0000; -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -struct LocalHttpConnection { - kind: String, - endpoint_url: String, - auth_token: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct DesktopBootstrap { - client_instance_id: String, - connection: LocalHttpConnection, - platform: &'static str, - runtime_environment: DesktopRuntimeEnvironment, - runtime_options: DesktopRuntimeOptions, - /// Names the paired computer whose App Server this Desktop uses instead of its own. - #[serde(skip_serializing_if = "Option::is_none")] - remote_computer: Option, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct DesktopRuntimeOptions { - wsl_distros: Vec, -} - #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] struct DesktopBootstrapContext { @@ -94,15 +61,9 @@ struct DesktopBootstrapContext { remote_computer: Option, } -#[derive(Clone, Debug, Serialize)] -struct DesktopBootstrapProgress { - message: String, - stage: &'static str, -} - +/// This Desktop's own settings and where its App Server lives. Which App Server +/// the window uses belongs to [`desktop_connection::DesktopConnections`]. struct DesktopState { - client_instance_id: String, - connection: Mutex>, runtime_paths: DesktopRuntimePaths, preferences: Mutex, preferences_path: PathBuf, @@ -117,8 +78,6 @@ impl DesktopState { wsl_resource_binary: PathBuf, ) -> Self { Self { - client_instance_id: format!("desktop-{}", uuid::Uuid::new_v4()), - connection: Mutex::new(None), runtime_paths, preferences: Mutex::new(preferences), preferences_path, @@ -126,25 +85,6 @@ impl DesktopState { } } - /// The native updater waits for this Desktop-owned listener to disappear before - /// handing the signed bundle to the platform installer. - fn app_server_endpoint_url(&self) -> Result { - self.connection - .lock() - .map_err(|_| "Desktop runtime state is unavailable.".to_string())? - .as_ref() - .map(|connection| connection.endpoint_url.clone()) - .ok_or_else(|| "Desktop runtime is not connected.".to_string()) - } - - /// False while a paired computer's App Server is in use: this Desktop then - /// started no process it could restore or must wait for. - fn owns_app_server(&self) -> bool { - self.preferences - .lock() - .map(|preferences| preferences.remote().is_none()) - .unwrap_or(true) - } } fn main() { @@ -175,14 +115,16 @@ fn main() { preferences_path, resource_dir.join("wsl").join("openaide-app-server"), )); + app.manage(desktop_connection::DesktopConnections::default()); app.manage(desktop_remote::DesktopRemoteState::new(&app_local_data)); app.manage(DesktopUpdateState::for_build( app_local_data.join("desktop-update-receipt.json"), )); app.manage(DesktopQuitState::default()); desktop_app_server_hold::install(app); + desktop_local_attention::install(app); #[cfg(target_os = "macos")] - install_macos_menu(app)?; + macos_menu::install(app)?; #[cfg(target_os = "macos")] configure_macos_webview_resize(app)?; #[cfg(all(target_os = "macos", debug_assertions))] @@ -225,11 +167,14 @@ fn main() { } }) .invoke_handler(tauri::generate_handler![ - desktop_bootstrap, + desktop_connection::desktop_bootstrap, desktop_bootstrap_context, - desktop_ensure_app_server, + desktop_connection::desktop_ensure_app_server, desktop_take_pending_quit, desktop_app_server_hold::complete_desktop_quit, + desktop_app_server_hold::desktop_update_prepare_local, + desktop_local_attention::desktop_local_attention_snapshot, + desktop_app_server_hold::desktop_update_finish_local, desktop_webview_log::record_desktop_telemetry, desktop_webview_log::record_desktop_webview_log, prepare_desktop_native_zoom, @@ -245,6 +190,7 @@ fn main() { desktop_remote::desktop_remote_snapshot, desktop_remote::desktop_remote_pair, desktop_remote::desktop_remote_use, + desktop_remote::desktop_remote_cancel, desktop_remote::desktop_remote_forget, desktop_update::desktop_update_snapshot, desktop_update::desktop_check_for_update, @@ -364,272 +310,6 @@ fn arm_macos_quit_watchdog(app: &tauri::AppHandle) { }); } -#[cfg(target_os = "macos")] -fn install_macos_menu(app: &mut tauri::App) -> tauri::Result<()> { - let new_task = MenuItemBuilder::with_id("new-task", "New Task") - .accelerator("CmdOrCtrl+N") - .build(app)?; - let open_project = MenuItemBuilder::with_id("open-project", "Add Project Folder…") - .accelerator("CmdOrCtrl+O") - .build(app)?; - let settings = MenuItemBuilder::with_id("settings", "Settings…") - .accelerator("CmdOrCtrl+,") - .build(app)?; - let check_for_updates = - MenuItemBuilder::with_id("check-for-updates", "Check for Updates…").build(app)?; - let quit = MenuItemBuilder::with_id("quit", "Quit OpenAIDE") - .accelerator("CmdOrCtrl+Q") - .build(app)?; - let application = SubmenuBuilder::new(app, "OpenAIDE") - .items(&[ - &settings, - &check_for_updates, - &PredefinedMenuItem::separator(app)?, - &quit, - ]) - .build()?; - let file = SubmenuBuilder::new(app, "File") - .items(&[&new_task, &open_project]) - .build()?; - let edit = SubmenuBuilder::new(app, "Edit") - .items(&[ - &PredefinedMenuItem::undo(app, None)?, - &PredefinedMenuItem::redo(app, None)?, - &PredefinedMenuItem::separator(app)?, - &PredefinedMenuItem::cut(app, None)?, - &PredefinedMenuItem::copy(app, None)?, - &PredefinedMenuItem::paste(app, None)?, - &PredefinedMenuItem::select_all(app, None)?, - ]) - .build()?; - app.set_menu( - MenuBuilder::new(app) - .items(&[&application, &file, &edit]) - .build()?, - )?; - Ok(()) -} - -#[tauri::command] -async fn desktop_bootstrap( - app: tauri::AppHandle, - state: tauri::State<'_, DesktopState>, -) -> Result { - let (environment, remote) = { - let preferences = state - .preferences - .lock() - .map_err(|_| "desktop bootstrap preferences unavailable")?; - ( - preferences.environment.clone(), - preferences.remote().cloned(), - ) - }; - let remote_computer = remote.as_ref().map(|computer| computer.name.clone()); - let runtime_options = DesktopRuntimeOptions { - wsl_distros: discover_wsl_distros(), - }; - if let Some(connection) = state - .connection - .lock() - .map_err(|_| "desktop connection state unavailable")? - .clone() - { - return Ok(DesktopBootstrap { - client_instance_id: state.client_instance_id.clone(), - connection, - platform: std::env::consts::OS, - runtime_environment: environment, - runtime_options, - remote_computer, - }); - } - - let operation_id = format!("desktop-bootstrap-{}", uuid::Uuid::new_v4()); - let connection = match &remote { - Some(computer) => { - emit_bootstrap_progress(&app, "remote", format!("Connecting to {}", computer.name)); - desktop_remote::connect(&app, computer).await? - } - None => { - launch_app_server_for_desktop( - state.runtime_paths.clone(), - state.wsl_resource_binary.clone(), - environment.clone(), - Some(app.clone()), - operation_id, - ) - .await? - } - }; - *state - .connection - .lock() - .map_err(|_| "desktop connection state unavailable")? = Some(connection.clone()); - let mut preferences = state - .preferences - .lock() - .map_err(|_| "desktop bootstrap preferences unavailable")?; - if preferences.previous_environment.take().is_some() { - write_bootstrap_preferences(&state.preferences_path, &preferences)?; - } - drop(preferences); - Ok(DesktopBootstrap { - client_instance_id: state.client_instance_id.clone(), - connection, - platform: std::env::consts::OS, - runtime_environment: environment, - runtime_options, - remote_computer, - }) -} - -struct HandoffFailure { - stage: &'static str, - user_message: String, -} - -/// Runs the App Server handoff and always reports one terminal line. Callers own the -/// decision to ask for a process; the handoff itself may elect an App Server that is -/// already running. Progress goes to the startup window only while it is listening. -async fn launch_app_server_for_desktop( - runtime_paths: DesktopRuntimePaths, - wsl_resource_binary: PathBuf, - environment: DesktopRuntimeEnvironment, - progress_app: Option, - operation_id: String, -) -> Result { - let started_at = Instant::now(); - desktop_log!("desktop_app_server_handoff_started operation_id={operation_id}"); - let joined = tauri::async_runtime::spawn_blocking(move || { - launch_app_server_handoff( - &runtime_paths, - &environment, - &wsl_resource_binary, - |stage, message| { - if let Some(app) = progress_app.as_ref() { - emit_bootstrap_progress(app, stage, message); - } - }, - ) - }) - .await; - match joined { - Err(_) => { - desktop_log!( - "desktop_app_server_handoff_completed operation_id={operation_id} outcome=failure stage=task_join duration_ms={}", - started_at.elapsed().as_millis() - ); - Err("App Server handoff task failed".to_string()) - } - Ok(Err(error)) => { - desktop_log!( - "desktop_app_server_handoff_completed operation_id={operation_id} outcome=failure stage={} duration_ms={}", - error.stage, - started_at.elapsed().as_millis() - ); - Err(error.user_message) - } - Ok(Ok(connection)) => { - desktop_log!( - "desktop_app_server_handoff_completed operation_id={operation_id} outcome=success duration_ms={}", - started_at.elapsed().as_millis() - ); - Ok(connection) - } - } -} - -/// A live App Server answers HTTP on its published loopback endpoint. Any HTTP status -/// proves a listener; the endpoint is loopback-only and token-guarded, so a 401 still -/// means the process is there. Only a connection failure means the process is gone. -async fn app_server_endpoint_reachable(connection: &LocalHttpConnection) -> bool { - let Ok(url) = url::Url::parse(&connection.endpoint_url) else { - return false; - }; - let Ok(client) = reqwest::Client::builder() - .connect_timeout(Duration::from_millis(750)) - .timeout(Duration::from_secs(2)) - .no_proxy() - .build() - else { - return false; - }; - client - .get(url) - .bearer_auth(&connection.auth_token) - .send() - .await - .is_ok() -} - -/// Restores the Desktop-owned App Server after machine sleep or an idle shutdown. An -/// already reachable endpoint is reused; a new process is published to the WebView -/// through the same replacement event the client already handles. -#[tauri::command] -async fn desktop_ensure_app_server( - app: tauri::AppHandle, - state: tauri::State<'_, DesktopState>, -) -> Result, String> { - // The gateway to a paired computer lives as long as this process, and the - // session's own retries reopen its connection. - if !state.owns_app_server() { - return Ok(None); - } - let operation_id = format!("desktop-ensure-app-server-{}", uuid::Uuid::new_v4()); - let started_at = Instant::now(); - let cached = state - .connection - .lock() - .map_err(|_| "desktop connection state unavailable")? - .clone(); - if let Some(connection) = &cached { - if app_server_endpoint_reachable(connection).await { - return Ok(None); - } - } - let environment = state - .preferences - .lock() - .map_err(|_| "desktop bootstrap preferences unavailable")? - .environment - .clone(); - desktop_log!( - "desktop_app_server_ensure_started operation_id={operation_id} had_connection={} reason_code={}", - cached.is_some(), - if cached.is_some() { - "endpoint_unreachable" - } else { - "no_connection" - } - ); - let connection = launch_app_server_for_desktop( - state.runtime_paths.clone(), - state.wsl_resource_binary.clone(), - environment, - None, - operation_id.clone(), - ) - .await?; - *state - .connection - .lock() - .map_err(|_| "desktop connection state unavailable")? = Some(connection.clone()); - let _ = app.emit("desktop-app-server-replaced", &connection); - desktop_log!( - "desktop_app_server_ensure_completed operation_id={operation_id} outcome=success duration_ms={}", - started_at.elapsed().as_millis() - ); - Ok(Some(connection)) -} - -fn emit_bootstrap_progress(app: &tauri::AppHandle, stage: &'static str, message: String) { - let _ = app.emit( - "desktop-bootstrap-progress", - DesktopBootstrapProgress { message, stage }, - ); -} - #[tauri::command] fn set_desktop_runtime_environment( app: tauri::AppHandle, @@ -726,125 +406,6 @@ fn desktop_prepare_project_folder( .map_err(|error| error.to_string()) } -fn launch_app_server_handoff( - runtime_paths: &DesktopRuntimePaths, - environment: &DesktopRuntimeEnvironment, - wsl_resource_binary: &Path, - progress: impl Fn(&'static str, String), -) -> Result { - if let DesktopRuntimeEnvironment::Wsl { distro } = environment { - return launch_wsl_app_server_handoff(distro, wsl_resource_binary, progress); - } - create_directory(&runtime_paths.storage_root, "storage_create")?; - create_directory(&runtime_paths.runtime_root, "runtime_create")?; - - let mut command = Command::new(&runtime_paths.app_server_binary); - #[cfg(target_os = "macos")] - macos_shell_path::apply_to(&mut command).map_err(|_| { - failure( - "shell_environment", - "OpenAIDE could not load your terminal PATH. Check your shell startup files and retry.", - ) - })?; - command - .env("OPENAIDE_APP_SERVER_PROTOCOL", "app-server-handoff") - .env("OPENAIDE_STORAGE_ROOT", &runtime_paths.storage_root) - .env("OPENAIDE_RUNTIME_ROOT", &runtime_paths.runtime_root) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()); - #[cfg(target_os = "windows")] - // The App Server remains a console binary for VSIX use, but Desktop owns it - // as a background child and must not flash a second console window. - command.creation_flags(CREATE_NO_WINDOW); - let mut child = command - .spawn() - .map_err(|_| failure("process_spawn", "OpenAIDE could not start its App Server."))?; - let mut startup = StartupChild::new(&mut child); - let stdout = startup.take_stdout().ok_or_else(|| { - failure( - "stdout_missing", - "The App Server did not provide startup information.", - ) - })?; - let line = read_handoff_line(stdout)?; - let connection: LocalHttpConnection = serde_json::from_str(line.trim()).map_err(|_| { - failure( - "json_invalid", - "The App Server returned invalid startup information.", - ) - })?; - validate_connection(&connection)?; - startup.accept(); - thread::spawn(move || { - let _ = child.wait(); - }); - Ok(connection) -} - -fn read_handoff_line(stdout: impl Read + Send + 'static) -> Result { - let (sender, receiver) = std::sync::mpsc::channel(); - thread::spawn(move || { - let mut line = String::new(); - let result = BufReader::new(stdout) - .take((MAX_HANDOFF_LINE_BYTES + 1) as u64) - .read_line(&mut line) - .map(|_| line); - let _ = sender.send(result); - }); - match receiver.recv_timeout(HANDOFF_TIMEOUT) { - Ok(Ok(line)) if !line.trim().is_empty() && line.len() <= MAX_HANDOFF_LINE_BYTES => Ok(line), - Ok(Ok(_)) => Err(failure( - "line_invalid", - "The App Server returned invalid startup information.", - )), - Ok(Err(_)) => Err(failure( - "line_read", - "OpenAIDE could not read App Server startup information.", - )), - Err(_) => Err(failure( - "timeout", - "The App Server did not become ready in time.", - )), - } -} - -fn create_directory(path: &std::path::Path, stage: &'static str) -> Result<(), HandoffFailure> { - std::fs::create_dir_all(path) - .map_err(|_| failure(stage, "OpenAIDE could not prepare its local storage.")) -} - -fn validate_connection(connection: &LocalHttpConnection) -> Result<(), HandoffFailure> { - if connection.kind != "localHttp" || connection.auth_token.len() < MIN_AUTH_TOKEN_BYTES { - return Err(failure( - "connection_invalid", - "The App Server returned invalid startup information.", - )); - } - let endpoint = url::Url::parse(&connection.endpoint_url).map_err(|_| { - failure( - "endpoint_invalid", - "The App Server returned an invalid local endpoint.", - ) - })?; - let loopback = endpoint.scheme() == "http" - && matches!(endpoint.host_str(), Some("127.0.0.1" | "localhost" | "::1")); - if !loopback { - return Err(failure( - "endpoint_not_loopback", - "The App Server returned an unsafe endpoint.", - )); - } - Ok(()) -} - -fn failure(stage: &'static str, user_message: &str) -> HandoffFailure { - HandoffFailure { - stage, - user_message: user_message.to_string(), - } -} - #[tauri::command] fn prepare_desktop_native_zoom(window: tauri::WebviewWindow) -> Result<(), String> { #[cfg(target_os = "macos")] diff --git a/apps/desktop/src-tauri/src/wsl_runtime.rs b/apps/desktop/src-tauri/src/wsl_runtime.rs index 632b7b04..18bcea8e 100644 --- a/apps/desktop/src-tauri/src/wsl_runtime.rs +++ b/apps/desktop/src-tauri/src/wsl_runtime.rs @@ -18,7 +18,9 @@ use crate::CREATE_NO_WINDOW; use crate::desktop_runtime::decode_wsl_list; #[cfg(target_os = "windows")] use crate::startup_process::StartupChild; -use crate::{HandoffFailure, LocalHttpConnection, failure, read_handoff_line, validate_connection}; +use crate::desktop_app_server_launch::{ + HandoffFailure, LocalHttpConnection, failure, read_handoff_line, validate_connection, +}; #[cfg(target_os = "windows")] pub(crate) fn discover_wsl_distros() -> Vec { diff --git a/apps/web/frontend/androidConnectionSettings.test.ts b/apps/web/frontend/androidConnectionSettings.test.ts index bea251ae..f14d5f93 100644 --- a/apps/web/frontend/androidConnectionSettings.test.ts +++ b/apps/web/frontend/androidConnectionSettings.test.ts @@ -94,4 +94,74 @@ describe("Android connection controls", () => { test.receive({ type: "result", id: request.id, accepted: false }); await expect(result).rejects.toThrow("unavailable"); }); + it("follows the phone's computers on every page, apart from the Settings state", () => { + const test = harness(); + test.attach(); + const computers = test.capability!.computers; + const changed = vi.fn(); + computers.subscribe(changed); + expect(computers.snapshot()).toBeUndefined(); + test.receive({ type: "computers", computers: [{ id: "studio", name: "Studio", current: true }] }); + expect(computers.snapshot()).toEqual({ device: "phone", computers: [{ id: "studio", name: "Studio", current: true }] }); + expect(computers.snapshot()).toBe(computers.snapshot()); + expect(test.capability!.snapshot()).toBeUndefined(); + expect(changed).toHaveBeenCalledOnce(); + }); + it("switches through the phone's own commands and resolves with what the switch came to", async () => { + const test = harness(); + test.attach(); + const sent = () => test.port.postMessage.mock.calls.map(([message]) => JSON.parse(message)); + const answer = (reply: Record) => test.receive({ type: "result", id: sent().at(-1).id, ...reply }); + const toStudio = test.capability!.computers.use("studio", "computer-switch-1"); + answer({ accepted: true, outcome: "switched" }); + await expect(toStudio).resolves.toBe("switched"); + const toPhone = test.capability!.computers.use(undefined); + answer({ accepted: true, outcome: "cancelled" }); + await expect(toPhone).resolves.toBe("cancelled"); + expect(sent().map((request) => request.command)).toEqual([ + { action: "use_computer", id: "studio", operationId: "computer-switch-1" }, + { action: "local" }, + ]); + }); + it("keeps the phone's sentence when a computer cannot be reached", async () => { + const test = harness(); + test.attach(); + const toStudio = test.capability!.computers.use("studio"); + const request = JSON.parse(test.port.postMessage.mock.calls[0][0]); + test.receive({ type: "result", id: request.id, accepted: false, error: "Your computer is unreachable." }); + await expect(toStudio).rejects.toThrow("Your computer is unreachable."); + }); + it("waits for a slow switch longer than for other commands, and cancels it", async () => { + vi.useFakeTimers(); + const test = harness(); + test.attach(); + let settled = false; + const toStudio = test.capability!.computers.use("studio").finally(() => { settled = true; }); + await vi.advanceTimersByTimeAsync(60_000); + expect(settled).toBe(false); + void test.capability!.computers.cancel!(); + const [switching, cancelling] = test.port.postMessage.mock.calls.map(([message]) => JSON.parse(message)); + expect(cancelling.command).toEqual({ action: "cancel_switch" }); + test.receive({ type: "result", id: cancelling.id, accepted: true, outcome: "cancelled" }); + test.receive({ type: "result", id: switching.id, accepted: true, outcome: "cancelled" }); + await expect(toStudio).resolves.toBe("cancelled"); + }); + it("waits for forgetting the computer in use as for a switch, and keeps the phone's sentence", async () => { + vi.useFakeTimers(); + const test = harness(); + test.attach(); + let settled = false; + const forgotten = test.capability!.computers.forget("studio").finally(() => { settled = true; }); + const request = JSON.parse(test.port.postMessage.mock.calls[0][0]); + expect(request.command).toEqual({ action: "forget_computer", id: "studio" }); + await vi.advanceTimersByTimeAsync(60_000); + expect(settled).toBe(false); + test.receive({ type: "result", id: request.id, accepted: true, outcome: "switched" }); + await expect(forgotten).resolves.toBeUndefined(); + + const failed = test.capability!.computers.forget("studio"); + const retried = JSON.parse(test.port.postMessage.mock.calls[1][0]); + test.receive({ type: "result", id: retried.id, accepted: false, error: "Your local workspace could not start." }); + await expect(failed).rejects.toThrow("Your local workspace could not start."); + }); }); diff --git a/apps/web/frontend/androidConnectionSettings.ts b/apps/web/frontend/androidConnectionSettings.ts index 3ab72215..e09b901a 100644 --- a/apps/web/frontend/androidConnectionSettings.ts +++ b/apps/web/frontend/androidConnectionSettings.ts @@ -1,10 +1,29 @@ +import type { ComputerSwitchResult, ComputersCapability, ComputersSnapshot } from "../../../packages/frontend/src/services/computers"; import type { ConnectionCommand, ConnectionSettings, ConnectionSnapshot } from "../../../packages/frontend/src/services/connectionSettings"; import type { DesktopUpdateCapability } from "../../../packages/frontend/src/services/frontendShell"; import { androidAppUpdates, type AndroidUpdateCommand } from "./androidAppUpdates"; import { androidSystemAppearance } from "./androidSystemAppearance"; -/** Phone controls the Android App serves over one native channel: connection settings and its own updates. */ -export type AndroidConnectionSettings = ConnectionSettings & { appUpdates: DesktopUpdateCapability }; +/** + * The phone's controls over one native channel: Settings state, which the phone + * sends only to its Settings sections; its own updates; and the computers, which + * it sends to every page so the switcher works anywhere (ADR-0070). + */ +export type AndroidConnectionSettings = ConnectionSettings & { + appUpdates: DesktopUpdateCapability; + computers: ComputersCapability; +}; + +/** The phone connects the chosen computer first (ADR-0070); these carry the page's correlation id and a Cancel. */ +type SwitchCommand = + | { action: "use_computer"; id: string; operationId?: string } + | { action: "local"; operationId?: string } + | { action: "cancel_switch" }; + +/** Forgetting the computer in use moves to this phone first, so it is answered like a switch. */ +const SWITCH_ACTIONS = new Set(["use_computer", "local", "cancel_switch", "forget_computer"]); +/** Bounds a switch the phone never answers; the user can Cancel well before it. */ +const SWITCH_TIMEOUT_MS = 180_000; export function androidConnectionSettings(host: Window): AndroidConnectionSettings | undefined { if (!/(?:^| )OpenAIDE-Android\/1(?: |$)/.test(host.navigator.userAgent)) return undefined; @@ -14,9 +33,10 @@ export function androidConnectionSettings(host: Window): AndroidConnectionSettin catch { } }); let state: ConnectionSnapshot | undefined; + let computers: ComputersSnapshot | undefined; let sequence = 0; const listeners = new Set<() => void>(); - const pending = new Map(); + const pending = new Map(); const unavailable = () => new Error("Phone settings are unavailable. Reopen Settings and try again."); const notify = () => listeners.forEach(listener => listener()); host.addEventListener("message", event => { @@ -31,12 +51,17 @@ export function androidConnectionSettings(host: Window): AndroidConnectionSettin try { const message = JSON.parse(event.data); if (message.type === "result") { - pending.get(message.id)?.finish(message.accepted ? undefined : unavailable()); + // A switch the phone refused carries the sentence for the user. + const refused = typeof message.error === "string" && message.error ? new Error(message.error) : unavailable(); + pending.get(message.id)?.finish(message.accepted ? undefined : refused, message.outcome); } else if (message.type === "state" && typeof message.state?.remote === "boolean") { state = message.state; notify(); } else if (message.type === "update") { updates.receive(message.update); + } else if (message.type === "computers" && Array.isArray(message.computers)) { + computers = { device: "phone", computers: message.computers }; + notify(); } } catch { } }; @@ -51,28 +76,46 @@ export function androidConnectionSettings(host: Window): AndroidConnectionSettin for (const request of pending.values()) request.finish(unavailable()); }); host.addEventListener("pageshow", () => appearance.refresh()); - const execute = (command: ConnectionCommand | AndroidUpdateCommand) => new Promise((resolve, reject) => { + /** Sends one command; a switch is answered when it ends, which a slow computer or this phone's start can delay. */ + const request = (command: ConnectionCommand | AndroidUpdateCommand | SwitchCommand) => new Promise((resolve, reject) => { const id = ++sequence; - const timeout = host.setTimeout(() => pending.get(id)?.finish(unavailable()), 8000); - const request = { + const switching = SWITCH_ACTIONS.has(command.action); + const timeout = host.setTimeout(() => pending.get(id)?.finish(unavailable()), switching ? SWITCH_TIMEOUT_MS : 8000); + const entry = { send: () => { try { port?.postMessage(JSON.stringify({ id, command })); } - catch { request.finish(unavailable()); } + catch { entry.finish(unavailable()); } }, - finish: (error?: Error) => { + finish: (error?: Error, outcome?: unknown) => { host.clearTimeout(timeout); pending.delete(id); - if (error) reject(error); else resolve(); + if (error) reject(error); else resolve(outcome); }, }; - pending.set(id, request); - request.send(); + pending.set(id, entry); + entry.send(); }); + const execute = async (command: ConnectionCommand | AndroidUpdateCommand) => { await request(command); }; + const switchTo = async (id: string | undefined, operationId: string | undefined): Promise => { + const outcome = await request(id ? { action: "use_computer", id, operationId } : { action: "local", operationId }); + // The phone replaces this page after it answers `switched`. + return outcome === "unchanged" || outcome === "cancelled" ? outcome : "switched"; + }; const updates = androidAppUpdates(execute); + const subscribe = (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; }; return { snapshot: () => state, - subscribe: listener => { listeners.add(listener); return () => { listeners.delete(listener); }; }, + subscribe, execute, appUpdates: updates.capability, + computers: { + snapshot: () => computers, + subscribe, + use: switchTo, + cancel: async () => { await request({ action: "cancel_switch" }); }, + // Resolves once forgotten, or once a Cancel kept the computer; forgetting the one in use replaces the page. + forget: async id => { await request({ action: "forget_computer", id }); }, + openPairing: () => execute({ action: "pair_setup" }), + }, }; } diff --git a/apps/web/frontend/webAppShell.ts b/apps/web/frontend/webAppShell.ts index ada0cdf7..7487451d 100644 --- a/apps/web/frontend/webAppShell.ts +++ b/apps/web/frontend/webAppShell.ts @@ -102,6 +102,7 @@ export function createWebAppShell(): FrontendShell { return { appearance, connectionSettings, + computers: connectionSettings?.computers, bootstrap, clipboard: { writeText: writeBrowserClipboardText }, // The Android App opens its camera for a capture input; browsers vary, and Chrome already offers one. diff --git a/docs/adr/0063-desktop-can-use-another-computers-app-server.md b/docs/adr/0063-desktop-can-use-another-computers-app-server.md index 359a60a3..c22f1114 100644 --- a/docs/adr/0063-desktop-can-use-another-computers-app-server.md +++ b/docs/adr/0063-desktop-can-use-another-computers-app-server.md @@ -1,16 +1,16 @@ # Desktop Can Use Another Computer's App Server -Status: accepted +Status: accepted; restart and App Server choices superseded by ADR-0070 The Desktop App on one computer can work with the App Server of another computer, the same way the Mobile App does ([ADR-0062](0062-remote-devices-are-app-server-clients.md)). It pairs by Pairing Code, is trusted by its own key pair, and reaches the other App Server over iroh. No account, address, or password is involved. ## Choice -A Desktop keeps its own App Server. The user chooses one place where agents work: this computer, or one of the computers it is paired with. Exactly one is in use at a time, and the choice is a Desktop bootstrap preference next to the runtime environment, so changing it restarts the Desktop like an environment change does. Pairing with a computer selects it. +A Desktop keeps its own App Server. The user chooses one place where agents work: this computer, or one of the computers it is paired with. Exactly one is in use at a time, and the choice is a Desktop bootstrap preference next to the runtime environment. Pairing with a computer selects it. Changing the choice no longer restarts the Desktop, and leaving this computer no longer stops its App Server ([ADR-0070](0070-switching-computers-is-in-place-and-keeps-work-running.md)). A Desktop pairs only in the invite direction: the other computer shows a code and this Desktop spends it. Between two computers either direction costs the user the same copy and paste, so the join-request direction and its listening endpoint are left out. -When a paired computer is in use the Desktop starts no App Server. Startup first reaches the paired computer, so an unreachable computer is named on the startup screen together with the way back to this computer. A removal or an incompatible version found later is reported once, with the same way back. +Startup with a paired computer in use first reaches the paired computer, so an unreachable computer is named on the startup screen together with the way back to this computer. A removal or an incompatible version found later is reported once, with the same way back. ## Mechanism @@ -29,7 +29,7 @@ While a paired computer is in use, the parts of the Desktop that assume a same-m - **A Desktop that is only a remote client.** Rejected: a computer that can run agents should keep doing so, and switching back must not need a reinstall. - **Using several computers at once.** Rejected for now: the Frontend renders one App Server's state, and merging Projects and Tasks of several is a product change of its own. -- **Applying the choice without a restart.** Rejected for now: the Frontend's session, caches, and shell capabilities are created once for one App Server. The restart is the mechanism the runtime environment already uses. +- **Applying the choice without a restart.** Rejected at first because the Frontend's session, caches, and shell capabilities are created once for one App Server; later adopted by reloading the WebView in a kept process ([ADR-0070](0070-switching-computers-is-in-place-and-keeps-work-running.md)). - **A second copy of the client in TypeScript inside the WebView.** Rejected: iroh has no WebView binding here, and the key must not live in web storage. ## Consequences diff --git a/docs/adr/0069-ssh-computers-are-started-like-termux.md b/docs/adr/0069-ssh-computers-are-started-like-termux.md new file mode 100644 index 00000000..a075617c --- /dev/null +++ b/docs/adr/0069-ssh-computers-are-started-like-termux.md @@ -0,0 +1,60 @@ +# SSH Computers Are Started Like Termux + +Status: proposed + +The Desktop App and the Mobile App can make a Linux computer reachable over SSH the place where agents work. The App Shell installs the App Server there, starts it, and reaches it the way the Mobile App reaches its App Server in Termux: it holds the per-process token and is a client of that App Server, with the loopback endpoint carried through the SSH connection instead of a local socket. No Pairing Code, key exchange with the App Server, or relay is involved; the user's SSH access is the proof. Pairing with a computer ([ADR-0063](0063-desktop-can-use-another-computers-app-server.md)) stays the way to reach an App Server someone else started. + +## Choice + +An SSH Computer is one more choice next to this computer and paired computers. Exactly one is in use at a time, and changing it restarts the App Shell as ADR-0063 does. The first release supports Linux on x64 only. + +The App Server on an SSH Computer uses that computer's default state root, the one the `remote` command uses ([ADR-0064](0064-a-terminal-command-keeps-the-app-server-reachable.md)). A computer has one OpenAIDE: Projects and Tasks started over SSH are the ones its paired devices see. + +### Versions + +The App Shell installs its own App Server release next to earlier ones, under one directory per version in the user's home. On connecting it finds one of four cases: + +- **Same version running, or none**: it attaches, or starts one. +- **Older version, compatible protocol**: it attaches like a Remote Device, by protocol compatibility ([ADR-0062](0062-remote-devices-are-app-server-clients.md)), and offers an update. +- **Older version, incompatible protocol**: it cannot attach until the computer is updated. +- **Newer version**: it refuses and asks the user to update this App Shell. + +An update happens only after the user confirms. The confirmation lists the other clients attached to that App Server with their kind, name, and version, marks the ones the update will strand, and counts running and waiting Tasks. Confirming sends the existing replacement request. An App Shell never replaces a newer App Server with an older one, so two clients of different versions cannot replace each other in turn. + +### Lifetime + +The App Server's lifetime rule does not change: it stops after its last same-machine client leaves. The App Shell starts a windowless keeper on the SSH Computer, a client like the `remote` command, outside the SSH session. The keeper stays while any of these holds: + +- a Task is running; +- a Task has waited for the user for less than 24 hours; +- an App Shell was attached within the last 30 minutes. + +When none holds, the keeper leaves and the App Server stops by the existing rule. A Task that was waiting is then interrupted and is resumed later the way any Task with a Native Session is. The App Shell also offers an explicit stop, which warns about running Tasks. + +## Mechanism + +- One Rust implementation in `openaide-remote-client` does the launch: detect the system and architecture, upload and verify the release, start the App Server through the user's login shell, read the endpoint line, apply the version cases, start the keeper, and forward the endpoint. It needs only two things from SSH: running a command and opening a channel to a loopback port. +- The Desktop App provides those through the system `ssh`, so the user's SSH configuration, jump hosts, agent, hardware keys, and interactive prompts keep working. Prompts are shown in the App Shell and never stored. +- The Mobile App embeds an SSH client. The phone generates its own key, kept encrypted with Android Keystore like its pairing key, and shows the public key to add on the computer. A password may be used once to add that key, and is not stored. Jump hosts are not supported on the phone at first. +- The App Shell downloads the matching release asset once, checks it against the SHA-256 built into the App Shell, and uploads it over SSH. The SSH Computer needs no internet access to receive it. The Windows Desktop reuses the Linux App Server it already bundles for WSL. +- Agents are installed and signed in on the SSH Computer, as in Termux. When Node is missing, a pinned Node runtime is installed in the user's home without elevated rights. +- The launcher detects a login manager that ends a user's processes when the SSH session closes and names the setting that keeps the keeper alive. +- The App Server Protocol gains the list of attached clients with kind, name, product version, and protocol version, as a minor version change. +- On the Mobile App, Task notifications for an SSH Computer come from the same foreground-service watch used for a paired computer, held over the SSH connection. +- Each launch stage, from connecting to forwarding, emits structured start and end events with a correlation identifier, outcome, duration, attempt count, and classified error, and no host names, user names, paths, or command output. + +## Considered Options + +- **SSH only to install, then pairing over iroh.** Rejected as the main path: traffic would depend on public relays, which many networks block, while SSH already reaches the computer. Pairing from an attached App Shell remains available. +- **A separate state root for SSH.** Rejected: it would split one computer's Projects and Native Sessions in two. +- **An idle timer in the App Server.** Rejected for the reason ADR-0064 gives: a second lifetime rule. The keeper is a client and decides only when to leave. +- **Updating without confirmation.** Rejected: replacement ends running Tasks and can strand other clients. +- **One embedded SSH client everywhere.** Rejected for the Desktop, because it would break existing SSH configurations. +- **The SSH Computer downloading the release itself.** Rejected: it needs internet access on the computer and a second download path. + +## Consequences + +- Secrets a user enters for an Agent or MCP server stay in the App Shell that entered them (ADR-0063), so Tasks that run while no App Shell is attached cannot read them. The App Shell says so. Moving that storage to the App Server's computer is a separate decision. +- Linux on arm64, macOS, and Windows computers need their own App Server builds and launch paths. +- The macOS Desktop and the Mobile App need network access once per release to fetch the Linux App Server. +- A waiting Task can keep the App Server alive for up to a day after its user left. diff --git a/docs/adr/0070-switching-computers-is-in-place-and-keeps-work-running.md b/docs/adr/0070-switching-computers-is-in-place-and-keeps-work-running.md new file mode 100644 index 00000000..39ca907b --- /dev/null +++ b/docs/adr/0070-switching-computers-is-in-place-and-keeps-work-running.md @@ -0,0 +1,40 @@ +# Switching Computers Is In Place and Keeps Work Running + +Status: accepted + +The Desktop App and the Mobile App let the user switch the computer in use from the Task navigation, without visiting Settings and without restarting. Switching changes which App Server the App Shell renders; it never stops work on the computer left behind. This replaces two choices of [ADR-0063](0063-desktop-can-use-another-computers-app-server.md): that a change restarts the Desktop, and that a Desktop using another computer starts no App Server. It applies to every kind of computer: this one, a paired computer, and an SSH Computer ([ADR-0069](0069-ssh-computers-are-started-like-termux.md)). + +## Choice + +Exactly one computer is in use at a time, as before. Merging several App Servers' Projects and Tasks into one navigation remains rejected. + +- **Switcher.** Once at least one computer is paired or added over SSH, a compact control sits at the end of the Settings row of the Task navigation. It names the computer in use with its connection state, and shows only an icon while this device is in use. It opens in place a list of this device first, then the other computers in the order they were added, with the one in use checked and the last item leading to Settings, Remote. Other computers' reachability is not probed. A user who never pairs sees no new control. The Desktop opens it with Ctrl/Cmd+Shift+S, and digits 1 to 9 pick a row. The shared Frontend owns it; each App Shell only applies the switch. +- **One row per App Server.** A row stands for one App Server, identified by its key, not for a route to it. When an SSH Computer turns out to host an App Server that is also paired, the two become one row with two routes; a switch tries the route that worked last and falls back to the other. +- **Connect first.** The switch connects to the new computer while the current one stays in use, shows Connecting with Cancel, and moves only once connected. An SSH prompt during that connection is answered there; cancelling it cancels the switch. A failure is shown in the list and leaves the user where they were. +- **Unsent input.** When the page holds an unsent draft, a selected attachment, or an upload in progress, the switch asks before discarding it. Otherwise it asks nothing. +- **Place.** Each App Shell remembers, per computer, the last Task, Project, or New Task Project the user had open, and returns there; a place that no longer exists, or a first visit, opens that computer's start page. This is presentation state kept on the device. +- **Work keeps running.** The Desktop keeps this computer's App Server held while another computer is in use, and releases it only when the Desktop quits. The Mobile App keeps the protection it gives the phone's own running Tasks while another computer is in use. +- **Attention.** Both App Shells notify for this device's Tasks, as well as the computer in use's. Activating such a notification switches back and opens the Task. Other computers are not watched. + +## Mechanism + +The Desktop keeps its process and replaces the connection: it ends the gateway and connection to the computer it leaves, starts or rejoins the target, and reloads the WebView so the Frontend, its caches, and shell capabilities are created again for one App Server, as the Mobile App already does. Each switch uses a new client instance identifier, so a server still holding the previous one in its reconnect grace does not hand back stale state. Connection failures are reported per computer rather than once per run. + +The Mobile App sends the list of computers and the one in use to every page, and accepts reading that state and the two switch commands from any page; every other connection command stays limited to the Settings sections. + +A switch emits structured start and terminal events under one correlation identifier from the choice through the reloaded Frontend's first ready state, with source and target kinds, route, outcome, duration, and classified error, and no names, keys, or addresses. + +## Considered Options + +- **Keep the restart.** Rejected: it made switching slow and lost the user's place, and the cost was paid on every switch. +- **Swap the connection inside the live page.** Rejected: the Frontend's session, caches, and shell capabilities are created once per load; reloading the WebView rebuilds them with the code that already exists. +- **Switch first, then report an unreachable computer.** Rejected: it leaves the user on a dead computer and could discard a draft for nothing. +- **Stop this computer's App Server when another is in use.** Rejected: switching would silently interrupt running Tasks and cut off this computer's own Remote Devices. +- **A switcher row or header shown to every user.** Rejected: most users never pair, and the control would make remote work look like the main path. +- **One row per route.** Rejected: two rows for the same Projects and Tasks invite the user to wonder which is real. + +## Consequences + +- A Desktop using another computer still runs its own App Server and the agents in it, so it uses this computer's resources until it quits. +- Unsent drafts are still lost on a confirmed switch. Keeping them per computer and Task is a separate decision. +- Two different App Servers on one machine, such as the Desktop's own state root and the default root an SSH Computer uses, appear as two rows told apart by their kind. Whether they should be one App Server belongs to ADR-0069 and the shared state root question of [ADR-0064](0064-a-terminal-command-keeps-the-app-server-reachable.md). diff --git a/docs/plans/computer-switcher.md b/docs/plans/computer-switcher.md new file mode 100644 index 00000000..c9df0b6c --- /dev/null +++ b/docs/plans/computer-switcher.md @@ -0,0 +1,53 @@ +# Computer switcher + +Status: agreed design ([ADR-0070](../adr/0070-switching-computers-is-in-place-and-keeps-work-running.md)); implementation planned in four independently shippable slices. + +## Pre-implementation findings + +- Both App Shells already keep several computers. Switching lives only in Settings, Remote (`RemoteSection.tsx`), behind two different shell capabilities: `connectionSettings` on the Mobile App and `remoteComputers` on the Desktop. +- `SidebarComputer` renders in the Desktop title bar and in the sidebar actions of other shells, and only while another computer is in use. On the Mobile App it never appears on a fresh page: the native side sends connection state only while a Settings section is open (`WorkspaceConnectionBridge.java:34`, `SetupPagePolicy.java:18`). +- The Desktop applies a switch with a full process relaunch (`restart_desktop`, `main.rs`). Obstacles to switching in place: `desktop_bootstrap` returns a cached connection, the remote gateway and client cannot be stopped (`remote-client/src/gateway.rs`), the failure dialog fires once per run, and `clientInstanceId` is per process. +- Leaving this computer on the Desktop lets its App Server drain about 40 seconds later (heartbeat timeout plus reconnect grace), interrupting running Tasks and stopping its Remote Devices endpoint. The Mobile App leaves Termux running but drops the wake lock protecting local work. +- Drafts live only in Frontend memory, and attachment handles belong to a client instance; any switch loses both. +- No App Shell remembers the last place: the Desktop route is an in-memory closure, the Mobile App loads `/` after each switch. +- No command palette exists; macOS has a native menu, other platforms map Ctrl shortcuts in the WebView (`desktopCommands.ts`). +- `main.rs` is above the 800-line limit and `AppSurfaces.tsx` is close to it; both need an extraction before they grow. +- Switch diagnostics have no source/target, outcome, end-to-end duration, or correlation across the restart. + +## Slice 1: shared switcher and Mobile App state on every page + +- Replace `connectionSettings`' computer fields and `remoteComputers` with one `computers` shell capability: snapshot (this device, computers with id, name, current), subscribe, `use(id)`, `forget(id)`, and the shell's pairing entry. Each shell adapts its native side to it. The Settings, Remote page reads the same capability. The link state comes from the Frontend's own session; `cancel()` arrives with connect-first in slice 3 and `kind` with SSH Computers. +- `ComputerSwitcher` in the sidebar footer's Settings row, shown once any computer exists: name and dot on another computer, icon on this device, words only for an unhealthy link. An upward listbox: this device, then computers in the order added, current checked, last item "Pair or manage computers…". Remove `SidebarComputer` and the Desktop title-bar lead. +- Ctrl/Cmd+Shift+S opens it (`desktopCommands` gains `switch-computer`, and macOS File ▸ Switch Computer…); arrows, Enter, and 1 to 9 inside. +- Mobile App: push the computer list (not the full connection state) to every page and on attach or resume; accept `use_computer` and `local` from any route; all other commands stay Settings-only. +- Tests: switcher unit and DOM tests (unpaired shows nothing, local icon, remote name, keyboard, narrow truncation); Android bridge policy test for the widened commands; one wide and one narrow viewport check. + +## Slice 2: Desktop switches in place and keeps its App Server + +- Extract the connection ownership from `main.rs` into a module that owns the current target and can replace it: local handoff, or remote gateway. Give `Gateway` and `RemoteClient` a stop handle in `openaide-remote-client`. +- New async command applies a switch: connect the target, retire the previous connection, issue a new client instance id, return the new bootstrap; the WebView reloads into it. Failure reporting is per computer, not once per run. +- The App Server hold always targets this computer's App Server and ends only on quit or update; startup with another computer in use also starts it, so switching back, this device's notifications, and this computer's own Remote Devices never depend on an earlier visit. Sleep recovery keeps working for it while another computer is in use. +- Runtime environment changes keep their restart. +- Tests: remote-client gateway stop and reconnect against the real App Server edge; Rust tests for the switch state machine (success, failure keeps current, cancel); a Desktop shell test that the WebView reload receives the new connection. + +## Slice 3: connect first, unsent input, and place + +- Connect first on both App Shells: the target row shows Connecting with Cancel; failure is shown in the list; nothing changes until connected. Android `RemoteNode` connects the target before disconnecting the previous one. +- Before connecting, the Frontend asks when the page has a draft, a selected attachment, or an upload in progress; declining connects nothing. Asking first needs no held connection between connect and apply, and a refused or cancelled connection still leaves the input in place. +- Place: the Frontend records the last Task, Project, or New Task Project per computer id in device storage and restores it after the first replica load, falling back to the start page when it no longer exists. Settings is never restored. Add an inline TODO where drafts are dropped, naming per-computer draft persistence as the follow-up. +- Tests: switcher state for connect, fail, cancel, and confirm; place restore with an existing and a missing Task. + +## Slice 4: notifications for this device + +- Mobile App: the background service watches the phone's App Server as well as the selected computer, and keeps the wake lock while local Tasks run. A tap on a local notification switches to this phone and opens the Task. +- Desktop: while another computer is in use, a native watcher keeps a held `taskAttention/wait` on this computer's App Server as a shell-hold client, so it never blocks a Desktop Update, and hands each changed list to the window. A second notification manager with its own receipts and notification ids applies the usual rules; activation switches back through the shared switch, so unsent input is still asked about, and the reloaded page opens the Task. +- Tests: the watch's baseline, in-sight, and one-per-Task rules for the second source; activation routing. + +## Diagnostics + +Each switch emits `computer_switch` start and terminal events under one correlation id carried across the reload: source and target kind, route, attempt, outcome (`switched`, `failed`, `cancelled`, `declined`), duration to the reloaded Frontend's first ready state, and classified error. No names, keys, addresses, or paths. The Desktop log and Android diagnostics both record it, and Support Export includes the Desktop's. + +## Open questions + +- Drafts are dropped with the page on a confirmed switch. Keeping them per computer and Task in device storage would let a switch lose nothing; until then the Frontend asks first. +- SSH Computers join the list once ADR-0069 lands, adding a `kind` to each computer and merged routes (ADR-0070). diff --git a/openaide-rs/app-server/src/remote_devices/edge_tests.rs b/openaide-rs/app-server/src/remote_devices/edge_tests.rs index 0b0ebbd7..68e87eb1 100644 --- a/openaide-rs/app-server/src/remote_devices/edge_tests.rs +++ b/openaide-rs/app-server/src/remote_devices/edge_tests.rs @@ -399,3 +399,45 @@ fn the_rust_client_records_how_far_a_failed_connect_got() { assert!(completed["relay_connected"].is_boolean()); assert!(completed["direct_addresses"].is_u64()); } + +#[test] +fn a_stopped_gateway_refuses_its_port_and_leaves_the_app_server() { + use openaide_remote_client::{Gateway, RemoteClient}; + + let fixture = fixture(); + let shown = fixture + .devices + .create_invite("desk".to_string(), AppServerTime::now().0) + .unwrap(); + fixture.wait_for_access(RemoteAccessState::On); + let invite = openaide_remote_client::InviteCode::decode(&shown.code).unwrap(); + let endpoint = fixture.device_endpoint(6, Vec::new()); + // A shell keeps its endpoint for the life of the process, across switches. + let _kept = endpoint.clone(); + + let (address, served) = fixture.run(async move { + let paired = openaide_remote_client::redeem(&endpoint, &invite, "Laptop", None, &quiet()) + .await + .unwrap(); + let client = std::sync::Arc::new(RemoteClient::new(endpoint, paired.id, quiet())); + let gateway = Gateway::start(client, quiet(), |_| {}).await.unwrap(); + let served = through_gateway( + gateway.address, + format!( + "POST /probe HTTP/1.1\r\nAuthorization: Bearer {}\r\n\r\n", + gateway.token + ), + ) + .await; + let address = gateway.address; + // Switching to another computer ends this one's gateway while the process lives on. + gateway.stop().await; + (address, served) + }); + + assert!(served.starts_with("HTTP/1.1 200 OK")); + assert!(std::net::TcpStream::connect(address).is_err()); + wait_until("the App Server to see the device leave", || { + fixture.devices.snapshot().devices[0].connection.is_none() + }); +} diff --git a/openaide-rs/remote-client/src/gateway.rs b/openaide-rs/remote-client/src/gateway.rs index 5be04e95..198b960d 100644 --- a/openaide-rs/remote-client/src/gateway.rs +++ b/openaide-rs/remote-client/src/gateway.rs @@ -21,13 +21,19 @@ const HEAD_TIMEOUT: Duration = Duration::from_secs(30); const HEAD_END: &[u8] = b"\r\n\r\n"; /// A running gateway: where it listens and the token its Frontend must send. +/// +/// It serves while this value lives. A shell that switches to another computer +/// calls [`Gateway::stop`]; dropping the value stops it the same way without +/// waiting. pub struct Gateway { pub address: SocketAddr, pub token: String, + stop: tokio::sync::oneshot::Sender<()>, + stopped: tokio::task::JoinHandle<()>, } impl Gateway { - /// Listens on a free loopback port for the rest of the process. + /// Listens on a free loopback port until stopped. /// /// `on_failure` hears the failures a retry cannot fix: this device was /// removed, or the two sides run incompatible versions. @@ -50,20 +56,49 @@ impl Gateway { requests: AtomicU64::new(0), }); (shared.log)("remote_gateway_listening", serde_json::json!({})); - tokio::spawn(async move { - loop { - match listener.accept().await { - Ok((socket, _)) => { - tokio::spawn(serve(socket, shared.clone())); - } - Err(_) => { - (shared.log)("remote_gateway_stopped", serde_json::json!({})); - break; - } + let (stop, mut stop_requested) = tokio::sync::oneshot::channel(); + let stopped = tokio::spawn(async move { + let mut serving = tokio::task::JoinSet::new(); + let reason = loop { + tokio::select! { + // A send and a dropped sender both mean the shell is done with it. + _ = &mut stop_requested => break "stopped", + accepted = listener.accept() => match accepted { + Ok((socket, _)) => { + serving.spawn(serve(socket, shared.clone())); + } + Err(_) => break "accept_failed", + }, + Some(_) = serving.join_next(), if !serving.is_empty() => {} } - } + }; + drop(listener); + let in_flight = serving.len(); + // Requests in flight belong to the page that is going away. + serving.shutdown().await; + shared.client.disconnect().await; + (shared.log)( + "remote_gateway_stopped", + serde_json::json!({ + "reason": reason, + "in_flight": in_flight, + "requests": shared.requests.load(Ordering::Relaxed), + }), + ); }); - Ok(Self { address, token }) + Ok(Self { + address, + token, + stop, + stopped, + }) + } + + /// Stops listening, ends the requests in flight, and closes the connection + /// to the App Server; returns once the port refuses connections. + pub async fn stop(self) { + let _ = self.stop.send(()); + let _ = self.stopped.await; } } diff --git a/packages/frontend/src/components/AppSurfaces.tsx b/packages/frontend/src/components/AppSurfaces.tsx index e93a78a2..bcac5d5a 100644 --- a/packages/frontend/src/components/AppSurfaces.tsx +++ b/packages/frontend/src/components/AppSurfaces.tsx @@ -16,7 +16,11 @@ import { updateTaskSurfaceTitle } from "../services/hostBridge"; import { ProjectFolderDialog } from "./ProjectFolderDialog"; import { ProjectRemoveDialog } from "./ProjectRemoveDialog"; import { currentFrontendShell, type DesktopUpdateCapability } from "../services/frontendShell"; -import { SidebarComputer, computerLink, useComputerInUse } from "./SidebarComputer"; +import { computerLink } from "./ComputerSwitcher"; +import { ComputerSwitchConfirm } from "./ComputerSwitchConfirm"; +import { useComputerPlace } from "./useComputerPlace"; +import { useComputers } from "../services/computers"; +import { computerSwitch } from "../services/computerSwitch"; export function AppSurfaces({ controller }: { controller: AppController }) { useInputModality(); @@ -35,8 +39,9 @@ export function AppSurfaces({ controller }: { controller: AppController }) { const { appServerError, navigation, settings } = view; const frontendShell = currentFrontendShell(); const desktopUpdate = useDesktopUpdateSnapshot(frontendShell?.desktopUpdates); - const computerInUse = useComputerInUse(frontendShell); + const computers = useComputers(frontendShell?.computers); const computerLinkState = computerLink(controller.backendConnectionState); + const [computerSwitcherRequest, setComputerSwitcherRequest] = useState(0); const updateAvailable = desktopUpdate?.kind === "available" || desktopUpdate?.kind === "downloading" || desktopUpdate?.kind === "readyToUpdate"; @@ -248,20 +253,25 @@ export function AppSurfaces({ controller }: { controller: AppController }) { } else if (command === "new-task") callbacks.navigation.openNewTask(); else if (command === "settings") callbacks.navigation.openSettings(); else if (command === "open-project") addProject?.(); + else if (command === "switch-computer") setComputerSwitcherRequest((request) => request + 1); }), [addProject, callbacks.navigation, frontendShell]); const desktopTaskHeaderIntegrated = Boolean(desktopWindow && renderableTaskSnapshot && !openingNativeSession); - const openComputerSettings = () => callbacks.navigation.openSettings(undefined, undefined, undefined, computerInUse?.settingsTab); - // A Desktop window names the computer in its own strip, beside the window - // buttons; other shells give it the first row of the navigation. - const desktopComputer = desktopWindow && computerInUse - ? - : undefined; - const sidebarComputerName = desktopWindow ? undefined : computerInUse?.name; + const openComputerSettings = () => callbacks.navigation.openSettings(undefined, undefined, undefined, "remote"); + const switchComputer = frontendShell?.computers ? computerSwitch(frontendShell.computers) : undefined; + useComputerPlace({ + backendReady, + bootstrap, + hasProject: (projectId) => navigation.projects.some((project) => project.projectId === projectId), + openNewTask: callbacks.navigation.openNewTask, + openTask: callbacks.navigation.openTask, + snapshot: computers, + }); + const computerSwitchConfirm = switchComputer ? : null; const desktopTitleBar = desktopWindow - ? + ? : undefined; const desktopSettingsTitleBar = desktopWindow ? ( - + ) : undefined; // Desktop chrome overlays product content so native controls never consume a // separate blank row. Active Tasks provide the draggable product header. @@ -447,14 +457,17 @@ export function AppSurfaces({ controller }: { controller: AppController }) { searchQuery={navigation.searchQuery} settingsStatus={updateAvailable ? "Update available" : undefined} computerLink={computerLinkState} - computerName={sidebarComputerName} - onComputer={openComputerSettings} + computers={computers} + computerSwitcherRequest={computerSwitcherRequest} + onManageComputers={openComputerSettings} + computerSwitch={switchComputer} showArchived={navigation.showArchived} taskListError={navigation.taskListError} tasks={visibleTasks} /> {projectFolderDialog} {projectRemoveDialog} + {computerSwitchConfirm} ); } @@ -586,8 +599,10 @@ export function AppSurfaces({ controller }: { controller: AppController }) { searchQuery={navigation.searchQuery} settingsStatus={updateAvailable ? "Update available" : undefined} computerLink={computerLinkState} - computerName={sidebarComputerName} - onComputer={closeAfter(openComputerSettings)} + computers={computers} + computerSwitcherRequest={computerSwitcherRequest} + onManageComputers={closeAfter(openComputerSettings)} + computerSwitch={switchComputer} showArchived={navigation.showArchived} taskListError={navigation.taskListError} tasks={visibleTasks} @@ -615,6 +630,8 @@ export function AppSurfaces({ controller }: { controller: AppController }) { > {projectFolderDialog} {projectRemoveDialog} + {computerSwitchConfirm} + {computerSwitchConfirm}
([]); const uploadsActive = fileUploads.some((upload) => upload.state !== "error"); useEffect(() => onFileUploadsChange(fileUploads), [fileUploads, onFileUploadsChange]); + useEffect(() => uploadsActive ? reportUnsentInput(() => true) : undefined, [uploadsActive]); useEffect(() => () => { fileAcquisitionActive.current = false; for (const controller of uploadControllers.current) controller.abort(); diff --git a/packages/frontend/src/components/ComputerSwitchConfirm.tsx b/packages/frontend/src/components/ComputerSwitchConfirm.tsx new file mode 100644 index 00000000..35ac9c80 --- /dev/null +++ b/packages/frontend/src/components/ComputerSwitchConfirm.tsx @@ -0,0 +1,27 @@ +import { computerName, type ComputersSnapshot } from "../services/computers"; +import { useComputerSwitch, type ComputerSwitch } from "../services/computerSwitch"; +import { SettingsConfirm } from "./settings/settingsKit"; + +/** + * Asks before a switch drops unsent input (ADR-0070). Rendered once beside the + * App's surfaces, so the Task navigation and Settings share it. + */ +export function ComputerSwitchConfirm({ snapshot, switcher }: { + snapshot: ComputersSnapshot | undefined; + switcher: ComputerSwitch; +}) { + const { confirming } = useComputerSwitch(switcher); + if (confirming === undefined) return null; + const computer = snapshot?.computers.find((candidate) => candidate.id === confirming); + const target = computer ? computerName(computer) : snapshot?.device === "phone" ? "this phone" : "this computer"; + return ( + switcher.decline()} + onConfirm={() => { void switcher.confirm(); }} + title={`Switch to ${target}?`} + /> + ); +} diff --git a/packages/frontend/src/components/ComputerSwitcher.dom.test.tsx b/packages/frontend/src/components/ComputerSwitcher.dom.test.tsx new file mode 100644 index 00000000..949fdeed --- /dev/null +++ b/packages/frontend/src/components/ComputerSwitcher.dom.test.tsx @@ -0,0 +1,217 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ComputerSwitchResult, ComputersSnapshot } from "../services/computers"; +import { computerSwitch } from "../services/computerSwitch"; +import { reportUnsentInput } from "../services/unsentInput"; +import { ComputerSwitchConfirm } from "./ComputerSwitchConfirm"; +import { ComputerSwitcher, computerLink, type ComputerLink } from "./ComputerSwitcher"; + +const studio = { id: "studio-key", name: "Studio", current: false }; +const laptop = { id: "laptop-key", name: "", current: false }; + +describe("ComputerSwitcher", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + vi.spyOn(console, "info").mockImplementation(() => undefined); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + }); + + function render( + snapshot: ComputersSnapshot | undefined, + { + link = "connected" as ComputerLink, + onUse = vi.fn(async (_id?: string, _operationId?: string): Promise => "switched"), + onManage = vi.fn(), + openRequest = 0, + cancel = undefined as (() => Promise) | undefined, + } = {}, + ) { + const switcher = computerSwitch({ + snapshot: () => snapshot, + subscribe: () => () => undefined, + use: onUse, + cancel, + forget: async () => undefined, + }); + act(() => root.render( + <> + + + , + )); + return { onUse, onManage }; + } + + const trigger = () => container.querySelector("button.computer-switcher-trigger"); + const menuItems = () => Array.from(document.querySelectorAll('[role="menu"] [role^="menuitem"]')); + const open = () => act(() => trigger()!.click()); + const isOpen = () => trigger()!.getAttribute("aria-expanded") === "true"; + // Keys reach the list from the focused row, as arrow navigation leaves them. + const press = (key: string) => act(() => { + menuItems()[0].dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key })); + }); + + it("stays out of the way until a computer is paired", () => { + render(undefined); + expect(trigger()).toBeNull(); + render({ device: "computer", computers: [] }); + expect(trigger()).toBeNull(); + }); + + it("shows only an icon while this device is in use", () => { + render({ device: "computer", computers: [studio] }); + expect(trigger()!.getAttribute("aria-label")).toBe("Working on this computer. Change computer"); + expect(trigger()!.textContent).toBe(""); + }); + + it("names the computer in use, with words only for an unhealthy link", () => { + render({ device: "computer", computers: [{ ...studio, current: true }] }); + expect(trigger()!.textContent).toBe("Studio"); + render({ device: "computer", computers: [{ ...studio, current: true }] }, { link: "reconnecting" }); + expect(trigger()!.textContent).toBe("StudioReconnecting…"); + expect(trigger()!.getAttribute("aria-label")).toBe("Working on Studio. Reconnecting…. Change computer"); + }); + + it("lists this device first, then computers in the order added, with the one in use checked", () => { + render({ device: "phone", computers: [{ ...studio, current: true }, laptop] }); + open(); + expect(menuItems().map((item) => [item.textContent, item.getAttribute("aria-checked")])).toEqual([ + ["1This phone", "false"], + ["2Studio", "true"], + ["3Remote computer", "false"], + ["Pair or manage computers…", null], + ]); + }); + + it("asks the shell to switch and shows the switch in progress", async () => { + let accept!: (result: ComputerSwitchResult) => void; + const onUse = vi.fn(() => new Promise((resolve) => { accept = resolve; })); + render({ device: "computer", computers: [studio] }, { onUse }); + open(); + act(() => menuItems()[1].click()); + expect(onUse).toHaveBeenCalledWith("studio-key", expect.any(String)); + expect(trigger()!.getAttribute("aria-busy")).toBe("true"); + open(); + expect(menuItems()[1].textContent).toContain("Connecting…"); + expect(menuItems().every((item) => item.getAttribute("aria-disabled") === "true" || item.textContent === "Pair or manage computers…")).toBe(true); + await act(async () => accept("unchanged")); + }); + + it("switches back to this device without an id", () => { + const { onUse } = render({ device: "computer", computers: [{ ...studio, current: true }] }); + open(); + act(() => menuItems()[0].click()); + expect(onUse).toHaveBeenCalledWith(undefined, expect.any(String)); + }); + + it("closes without switching when the computer in use is chosen", () => { + const { onUse } = render({ device: "computer", computers: [{ ...studio, current: true }] }); + open(); + act(() => menuItems()[1].click()); + expect(onUse).not.toHaveBeenCalled(); + expect(isOpen()).toBe(false); + }); + + it("keeps the user in place with the shell's sentence when the switch is refused", async () => { + const onUse = vi.fn(async (): Promise => { throw new Error("Studio could not be reached."); }); + render({ device: "computer", computers: [studio] }, { onUse }); + open(); + await act(async () => menuItems()[1].click()); + expect(document.querySelector('[role="menu"] [role="alert"]')!.textContent).toBe("Studio could not be reached."); + expect(trigger()!.getAttribute("aria-busy")).toBeNull(); + await act(async () => menuItems()[1].click()); + expect(onUse).toHaveBeenCalledTimes(2); + }); + + it("picks a row by its number", () => { + const { onUse } = render({ device: "computer", computers: [studio, laptop] }); + open(); + press("3"); + expect(onUse).toHaveBeenCalledWith("laptop-key", expect.any(String)); + }); + + it("ignores numbers without a row", () => { + const { onUse } = render({ device: "computer", computers: [studio] }); + open(); + press("5"); + expect(onUse).not.toHaveBeenCalled(); + }); + + it("leads to Settings to pair or manage", () => { + const { onManage } = render({ device: "computer", computers: [studio] }); + open(); + act(() => menuItems().at(-1)!.click()); + expect(onManage).toHaveBeenCalledOnce(); + expect(isOpen()).toBe(false); + }); + + it("opens when a shell command asks for it", () => { + const snapshot: ComputersSnapshot = { device: "computer", computers: [studio] }; + render(snapshot, { openRequest: 0 }); + expect(isOpen()).toBe(false); + render(snapshot, { openRequest: 1 }); + expect(isOpen()).toBe(true); + }); + + it("reads the link from the session state", () => { + expect(computerLink({ status: "ready" })).toBe("connected"); + expect(computerLink({ status: "ready", unresponsive: true })).toBe("notResponding"); + expect(computerLink({ status: "connecting" })).toBe("connecting"); + expect(computerLink({ status: "reconnecting", message: "" })).toBe("reconnecting"); + expect(computerLink({ status: "unavailable", message: "" })).toBe("notConnected"); + }); + + it("offers Cancel while the shell is still connecting", async () => { + let answer!: (result: ComputerSwitchResult) => void; + const onUse = vi.fn(() => new Promise((resolve) => { answer = resolve; })); + const cancel = vi.fn(async () => answer("cancelled")); + render({ device: "computer", computers: [studio] }, { onUse, cancel }); + open(); + act(() => menuItems()[1].click()); + open(); + const cancelItem = menuItems().find((item) => item.textContent === "Cancel switching")!; + await act(async () => cancelItem.click()); + expect(cancel).toHaveBeenCalledOnce(); + open(); + expect(menuItems().some((item) => item.textContent === "Cancel switching")).toBe(false); + expect(trigger()!.getAttribute("aria-busy")).toBeNull(); + }); + + it("asks before dropping unsent input and switches only once the user agrees", async () => { + const stopReporting = reportUnsentInput(() => true); + try { + const { onUse } = render({ device: "computer", computers: [studio] }); + open(); + act(() => menuItems()[1].click()); + const dialog = () => document.querySelector('[role="dialog"]'); + expect(dialog()!.textContent).toContain("Switch to Studio?"); + expect(onUse).not.toHaveBeenCalled(); + const button = (name: string) => Array.from(dialog()!.querySelectorAll("button")).find((item) => item.textContent === name)!; + await act(async () => button("Cancel").click()); + expect(dialog()).toBeNull(); + expect(onUse).not.toHaveBeenCalled(); + open(); + act(() => menuItems()[1].click()); + await act(async () => button("Discard and switch").click()); + expect(onUse).toHaveBeenCalledWith("studio-key", expect.any(String)); + expect(dialog()).toBeNull(); + } finally { + stopReporting(); + } + }); +}); diff --git a/packages/frontend/src/components/ComputerSwitcher.tsx b/packages/frontend/src/components/ComputerSwitcher.tsx new file mode 100644 index 00000000..aabe55fc --- /dev/null +++ b/packages/frontend/src/components/ComputerSwitcher.tsx @@ -0,0 +1,147 @@ +import { useEffect, useState, type KeyboardEvent } from "react"; +import { Check, Monitor, MonitorSmartphone } from "lucide-react"; +import { computerName, currentComputer, type ComputersSnapshot } from "../services/computers"; +import { useComputerSwitch, type ComputerSwitch } from "../services/computerSwitch"; +import type { BackendConnectionState } from "./appControllerBackendLifecycle"; +import { PopupMenu } from "./Popup"; + +export type ComputerLink = "connected" | "connecting" | "reconnecting" | "notResponding" | "notConnected"; + +const linkLabels: Record = { + connected: "Connected", + connecting: "Connecting…", + reconnecting: "Reconnecting…", + notResponding: "Not responding", + notConnected: "Not connected", +}; + +export function computerLink(state: BackendConnectionState): ComputerLink { + switch (state.status) { + case "ready": + return state.unresponsive ? "notResponding" : "connected"; + case "connecting": + return "connecting"; + case "reconnecting": + return "reconnecting"; + case "unavailable": + return "notConnected"; + } +} + +/** "" stands for this device in the pending and failed switch. */ +type Row = { id: string; label: string; current: boolean }; + +/** + * Switches the computer in use in place (ADR-0070). It appears only once + * another computer exists, so a user who never pairs sees nothing new. The + * trigger names the other computer in use, with the dot alone saying the link + * is healthy; this device needs only the icon. + */ +export function ComputerSwitcher({ link, onManage, openRequest = 0, snapshot, switcher }: { + link: ComputerLink; + onManage: () => void; + /** Increments when a shell command asks to open the list. */ + openRequest?: number; + snapshot: ComputersSnapshot | undefined; + /** The shell's switch, shared with Settings so both show the same attempt. */ + switcher: ComputerSwitch; +}) { + const [open, setOpen] = useState(false); + const { cancellable, pending, failed } = useComputerSwitch(switcher); + useEffect(() => { + if (openRequest > 0) setOpen(true); + }, [openRequest]); + if (!snapshot?.computers.length) return null; + + const inUse = currentComputer(snapshot); + const here = snapshot.device === "phone" ? "This phone" : "This computer"; + const rows: Row[] = [ + { id: "", label: here, current: !inUse }, + ...snapshot.computers.map((computer) => ({ id: computer.id, label: computerName(computer), current: computer.current })), + ]; + const label = linkLabels[link]; + const triggerLabel = inUse + ? `Working on ${computerName(inUse)}. ${label}. Change computer` + : `Working on ${here.toLowerCase()}. Change computer`; + + const choose = (row: Row) => { + if (pending !== undefined) return; + if (row.current) { + setOpen(false); + return; + } + void switcher.request(row.id || undefined); + }; + const pickByNumber = (event: KeyboardEvent) => { + if (event.altKey || event.ctrlKey || event.metaKey || !/^[1-9]$/.test(event.key)) return; + const row = rows[Number(event.key) - 1]; + if (!row) return; + event.preventDefault(); + choose(row); + }; + + return ( + ( + + )} + > +
+ {rows.map((row, index) => ( + + ))} + {pending !== undefined && cancellable ? ( + + ) : null} + {failed ?

{failed.message}

: null} + +
+
+ ); +} + diff --git a/packages/frontend/src/components/DesktopTitleBar.tsx b/packages/frontend/src/components/DesktopTitleBar.tsx index d87d74ec..e23e46fa 100644 --- a/packages/frontend/src/components/DesktopTitleBar.tsx +++ b/packages/frontend/src/components/DesktopTitleBar.tsx @@ -1,16 +1,13 @@ -import { type MouseEvent, type ReactNode } from "react"; +import { type MouseEvent } from "react"; import { Minus, Square, X } from "lucide-react"; import type { DesktopWindowCapability } from "../services/frontendShell"; /** Renders product content inside shell-owned native window chrome. */ export function DesktopTitleBar({ integrated = false, - sidebarLead, window: desktopWindow, }: { integrated?: boolean; - /** Shown in the strip above the navigation, beside the native window buttons. */ - sidebarLead?: ReactNode; window: DesktopWindowCapability; }) { const windowsChrome = desktopWindow.platform === "windows"; @@ -25,7 +22,7 @@ export function DesktopTitleBar({ integrated ? "desktop-title-bar-integrated" : undefined, ].filter(Boolean).join(" ")} > -
{sidebarLead}
+
{windowsChrome && !integrated ? OpenAIDE : null}
diff --git a/packages/frontend/src/components/Sidebar.tsx b/packages/frontend/src/components/Sidebar.tsx index 8e37ebc4..885ec98e 100644 --- a/packages/frontend/src/components/Sidebar.tsx +++ b/packages/frontend/src/components/Sidebar.tsx @@ -18,7 +18,9 @@ import { useScrollOverflow } from "./useScrollOverflow"; import { WorkspaceSetupPrompt } from "./WorkspaceSetupPrompt"; import { AgentDisableDialog } from "./AgentDisableDialog"; import { useAgentDisableConfirmation } from "./useAgentDisableConfirmation"; -import { SidebarComputer, type ComputerLink } from "./SidebarComputer"; +import { ComputerSwitcher, type ComputerLink } from "./ComputerSwitcher"; +import type { ComputersSnapshot } from "../services/computers"; +import type { ComputerSwitch } from "../services/computerSwitch"; import type { AgentDisableOutcome } from "../intents/agentSettingsIntents"; import { agentIntegrationInstallingLabel } from "./agentActivityPresentation"; @@ -73,10 +75,14 @@ type SidebarProps = { searchQuery: string; settingsStatus?: string; settingsActive?: boolean; - /** The other computer in use; absent while this device does the work or the window strip names it. */ - computerName?: string; + /** The shell's computers; the switcher appears once one other than this device exists. */ + computers?: ComputersSnapshot; computerLink?: ComputerLink; - onComputer?: () => void; + /** Increments when a shell command asks to open the computer list. */ + computerSwitcherRequest?: number; + /** Present when the shell can switch computers. */ + computerSwitch?: ComputerSwitch; + onManageComputers?: () => void; showArchived: boolean; taskListError?: string; tasks: TaskSummary[]; @@ -130,9 +136,11 @@ export const Sidebar = memo(function Sidebar({ onToggleArchived, searchQuery, settingsStatus, - computerName, + computers, computerLink, - onComputer, + computerSwitcherRequest, + computerSwitch, + onManageComputers, settingsActive = false, showArchived, taskListError, @@ -222,9 +230,6 @@ export const Sidebar = memo(function Sidebar({
) : null}
- {computerName && onComputer && !showArchived ? ( - - ) : null} {!showArchived ? ) : null} - +
+ + {computerSwitch && onManageComputers ? ( + + ) : null} +
{pendingDisable ? ( { - (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -}); - -function inUse(shell: Partial) { - let seen: ComputerInUse | undefined; - function Probe() { - seen = useComputerInUse(shell as FrontendShell); - return null; - } - act(() => { create(); }); - return { seen: () => seen }; -} - -function phone(initial: Partial) { - let snapshot = initial as ConnectionSnapshot; - const listeners = new Set<() => void>(); - return { - capability: { - snapshot: () => snapshot, - subscribe: (listener: () => void) => { listeners.add(listener); return () => listeners.delete(listener); }, - execute: vi.fn(), - }, - set(next: Partial) { - snapshot = next as ConnectionSnapshot; - listeners.forEach((listener) => listener()); - }, - }; -} - -it("names no computer while this device does the work", () => { - expect(inUse({}).seen()).toBeUndefined(); - expect(inUse({ remoteComputers: { list: vi.fn(), pair: vi.fn(), use: vi.fn(), forget: vi.fn() } }).seen()).toBeUndefined(); - expect(inUse({ connectionSettings: phone({ remote: false, computer: "Studio" }).capability }).seen()).toBeUndefined(); -}); - -it("names the Desktop's paired computer and its Settings tab", () => { - const shell = { remoteComputers: { current: "Studio", list: vi.fn(), pair: vi.fn(), use: vi.fn(), forget: vi.fn() } }; - expect(inUse(shell).seen()).toEqual({ name: "Studio", settingsTab: "remote" }); -}); - -it("follows the phone's choice of computer as it changes", () => { - const source = phone({ remote: true, computer: "Studio" }); - const probe = inUse({ connectionSettings: source.capability }); - expect(probe.seen()).toEqual({ name: "Studio", settingsTab: "remote" }); - act(() => source.set({ remote: true, computer: "" })); - expect(probe.seen()).toEqual({ name: "Another computer", settingsTab: "remote" }); - act(() => source.set({ remote: false, computer: "" })); - expect(probe.seen()).toBeUndefined(); -}); - -it("reads the link from the session state", () => { - expect(computerLink({ status: "ready" })).toBe("connected"); - expect(computerLink({ status: "ready", unresponsive: true })).toBe("notResponding"); - expect(computerLink({ status: "connecting" })).toBe("connecting"); - expect(computerLink({ status: "reconnecting", message: "" })).toBe("reconnecting"); - expect(computerLink({ status: "unavailable", message: "" })).toBe("notConnected"); -}); - -it("shows the computer and its link, and opens the place that changes it", () => { - const onOpen = vi.fn(); - let tree!: ReturnType; - act(() => { tree = create(); }); - const button = tree.root.findByType("button"); - expect(button.props["data-link"]).toBe("reconnecting"); - expect(button.props["aria-label"]).toBe("Working on Studio. Reconnecting…. Change computer"); - expect(tree.root.findByProps({ className: "sidebar-computer-link" }).children).toEqual(["Reconnecting…"]); - act(() => button.props.onClick()); - expect(onOpen).toHaveBeenCalledOnce(); -}); - -it("lets the dot alone speak for a healthy link", () => { - let tree!: ReturnType; - act(() => { tree = create(); }); - expect(tree.root.findAllByProps({ className: "sidebar-computer-link" })).toHaveLength(0); - expect(tree.root.findAllByProps({ className: "sidebar-computer-dot" })).toHaveLength(1); -}); diff --git a/packages/frontend/src/components/SidebarComputer.tsx b/packages/frontend/src/components/SidebarComputer.tsx deleted file mode 100644 index f4f5bdda..00000000 --- a/packages/frontend/src/components/SidebarComputer.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { useSyncExternalStore } from "react"; -import { MonitorSmartphone } from "lucide-react"; -import type { SettingsTabId } from "@openaide/app-shell-contracts"; -import type { FrontendShell } from "../services/frontendShell"; -import type { BackendConnectionState } from "./appControllerBackendLifecycle"; - -export type ComputerLink = "connected" | "connecting" | "reconnecting" | "notResponding" | "notConnected"; - -/** The other computer this shell works on, and the Settings tab that changes it. */ -export type ComputerInUse = { name: string; settingsTab: SettingsTabId }; - -const linkLabels: Record = { - connected: "Connected", - connecting: "Connecting…", - reconnecting: "Reconnecting…", - notResponding: "Not responding", - notConnected: "Not connected", -}; - -const noSubscription = () => () => {}; -const noSnapshot = () => undefined; - -/** - * Names the other computer only while one is in use; working on this device - * needs no indicator. The phone's choice changes live, the Desktop's is fixed - * for the shell's run. - */ -export function useComputerInUse(shell: FrontendShell | undefined): ComputerInUse | undefined { - const phone = shell?.connectionSettings; - const snapshot = useSyncExternalStore( - phone?.subscribe ?? noSubscription, - phone?.snapshot ?? noSnapshot, - phone?.snapshot ?? noSnapshot, - ); - if (snapshot?.remote) { - return { name: snapshot.computer || "Another computer", settingsTab: "remote" }; - } - const desktop = shell?.remoteComputers?.current; - return desktop ? { name: desktop, settingsTab: "remote" } : undefined; -} - -export function computerLink(state: BackendConnectionState): ComputerLink { - switch (state.status) { - case "ready": - return state.unresponsive ? "notResponding" : "connected"; - case "connecting": - return "connecting"; - case "reconnecting": - return "reconnecting"; - case "unavailable": - return "notConnected"; - } -} - -/** - * Heads the navigation with the computer its projects and tasks live on. The - * dot alone says the link is healthy; words appear only when it is not. - */ -export function SidebarComputer({ link, name, onOpen }: { - link: ComputerLink; - name: string; - onOpen: () => void; -}) { - const label = linkLabels[link]; - return ( - - ); -} diff --git a/packages/frontend/src/components/appController.ts b/packages/frontend/src/components/appController.ts index 051b7366..0d7b234f 100644 --- a/packages/frontend/src/components/appController.ts +++ b/packages/frontend/src/components/appController.ts @@ -9,6 +9,8 @@ import { } from "../services/hostBridge"; import { clientInstanceIdForBootstrap } from "../services/backendInitialization"; import type { ComposerAttachmentResourceOwner } from "../services/attachmentResources"; +import { reportUnsentInput } from "../services/unsentInput"; +import { hasUnsentComposerInput } from "./composerDraftPolicy"; import { appReducer, bindAppServerReplicaEpoch, @@ -153,6 +155,9 @@ function useAppControllerCore({ const initialBootstrap = useMemo(() => getBootstrap(), []); const clientInstanceId = useMemo(() => clientInstanceIdForBootstrap(initialBootstrap), [initialBootstrap]); const [state, dispatch] = useReducer(appReducer, undefined, createInitialState); + const latestState = useRef(state); + latestState.current = state; + useEffect(() => reportUnsentInput(() => hasUnsentComposerInput(latestState.current)), []); const [preferences, setPreferences] = useState(initialBootstrap.preferences ?? { composer_submit_shortcut: "enter" }); const [agents, setAgents] = useState(undefined); const [navigationFocusedTaskId, setNavigationFocusedTaskId] = useState( diff --git a/packages/frontend/src/components/composerDraftPolicy.ts b/packages/frontend/src/components/composerDraftPolicy.ts index 8b3f9d85..f051b8e2 100644 --- a/packages/frontend/src/components/composerDraftPolicy.ts +++ b/packages/frontend/src/components/composerDraftPolicy.ts @@ -1,7 +1,19 @@ +import type { AppState } from "../state/store"; + export function hasComposerContent(value: string, attachmentCount: number) { return value.trim().length > 0 || attachmentCount > 0; } +/** + * Whether any composer holds text, attachments, or a send not yet accepted. + * These live only in this page, so replacing the page loses them. + */ +export function hasUnsentComposerInput(state: Pick) { + const unsent = (input: { prompt: string; context: unknown[]; pending?: unknown }) => + hasComposerContent(input.prompt, input.context.length) || Boolean(input.pending); + return unsent(state.newTask) || Object.values(state.taskInputs).some(unsent); +} + export function hasComposerText(value: string) { return value.trim().length > 0; } diff --git a/packages/frontend/src/components/settings/GeneralSection.tsx b/packages/frontend/src/components/settings/GeneralSection.tsx index c5d21233..d56d65fb 100644 --- a/packages/frontend/src/components/settings/GeneralSection.tsx +++ b/packages/frontend/src/components/settings/GeneralSection.tsx @@ -11,6 +11,7 @@ import { } from "../../services/frontendShell"; import { usesMobileComposerBehavior } from "../mobileComposerBehavior"; import type { PhoneConnection } from "./phoneConnection"; +import { currentComputer } from "../../services/computers"; import { settingsInfo } from "./settingsInfo"; import { SettingsButton, SettingsChoice, SettingsInlineError, SettingsKeys, SettingsNotice, SettingsSelect, SettingsSwitch } from "./settingsKit"; import type { SettingsGroup, SettingsRow, SettingsSection } from "./settingsModel"; @@ -36,7 +37,7 @@ export function useGeneralSection({ }: GeneralSectionProps): SettingsSection { const shell = currentFrontendShell(); // While a paired computer is in use, its own runtime applies and this choice has no effect. - const runtime = useRuntimeGroup(shell?.remoteComputers?.current ? undefined : shell?.desktopRuntime); + const runtime = useRuntimeGroup(currentComputer(shell?.computers?.snapshot()) ? undefined : shell?.desktopRuntime); const updates = useUpdateGroup(shell?.desktopUpdates); const enterSends = preferences.composer_submit_shortcut === "enter"; const everyday: SettingsRow[] = []; diff --git a/packages/frontend/src/components/settings/RemoteSection.tsx b/packages/frontend/src/components/settings/RemoteSection.tsx index add1036e..8afa467e 100644 --- a/packages/frontend/src/components/settings/RemoteSection.tsx +++ b/packages/frontend/src/components/settings/RemoteSection.tsx @@ -2,8 +2,9 @@ import type { DeviceCollectionSnapshot, RemoteDeviceSummary } from "@openaide/ap import { Laptop, Monitor, Plus, QrCode, Smartphone, Wifi } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import type { RemoteDeviceIntents } from "../../intents/remoteDeviceIntents"; -import { currentFrontendShell, type RemoteComputer, type RemoteComputersCapability } from "../../services/frontendShell"; -import type { ConnectionComputer } from "../../services/connectionSettings"; +import { computerName, currentComputer, useComputers, type ComputersCapability, type ComputersSnapshot, type ShellComputer } from "../../services/computers"; +import { computerSwitch, useComputerSwitch } from "../../services/computerSwitch"; +import { currentFrontendShell } from "../../services/frontendShell"; import { PopupDialog } from "../Popup"; import { InviteDialog, JoinDialog } from "./devicePairingDialogs"; import type { PhoneConnection } from "./phoneConnection"; @@ -19,8 +20,7 @@ export type RemoteSectionProps = { type Dialog = | { kind: "pair" } - | { kind: "forget"; computer: RemoteComputer } - | { kind: "forget-phone"; computer: ConnectionComputer } + | { kind: "forget"; computer: ShellComputer } | { kind: "invite" } | { kind: "join" } | { kind: "remove"; device: RemoteDeviceSummary }; @@ -30,18 +30,24 @@ type Dialog = * connected to, and which devices may connect to that computer. */ export function useRemoteSection({ deviceIntents, devices, phone }: RemoteSectionProps): SettingsSection | undefined { - const capability = currentFrontendShell()?.remoteComputers; - const desktop = useRemoteComputers(capability); + const capability = currentFrontendShell()?.computers; + const computers = useSettingsComputers(capability); const [dialog, setDialog] = useState(); const close = () => setDialog(undefined); - if (!capability && !phone && !deviceIntents) return undefined; + if (!capability && !deviceIntents) return undefined; - const remoteName = capability?.current ?? (phone?.state?.remote ? phone.state.computer || "Remote computer" : undefined); + const inUse = currentComputer(computers.snapshot); + const remoteName = inUse ? computerName(inUse) : undefined; + const device = computers.snapshot?.device === "phone" || phone ? "phone" : "computer"; + const here = `this ${device}`; const groups: SettingsGroup[] = []; - if (capability) groups.push(desktopComputersGroup(desktop, () => setDialog({ kind: "pair" }), (computer) => setDialog({ kind: "forget", computer }))); - else if (phone) groups.push(phoneComputersGroup(phone, (computer) => setDialog({ kind: "forget-phone", computer }))); + if (capability) { + groups.push(computersGroup(computers, phone, () => { + if (capability.pair) setDialog({ kind: "pair" }); + else void computers.openPairing(); + }, (computer) => setDialog({ kind: "forget", computer }))); + } if (deviceIntents) { - const here = phone ? "this phone" : "this computer"; // The App Server names itself; a browser tab on another machine is not "this computer". groups.push(devicesGroup(devices, devices?.serverName || remoteName || here, (next) => setDialog(next))); } @@ -54,27 +60,16 @@ export function useRemoteSection({ deviceIntents, devices, phone }: RemoteSectio groups, overlay: dialog ? ( <> - {dialog.kind === "pair" && capability ? : null} - {dialog.kind === "forget" && capability ? ( - desktop.forget(dialog.computer.id)} - pendingLabel="Forgetting…" - title={`Forget ${dialog.computer.name}?`} - /> - ) : null} - {dialog.kind === "forget-phone" && phone ? ( + {dialog.kind === "pair" && capability?.pair ? : null} + {dialog.kind === "forget" ? ( phone.execute({ action: "forget_computer", id: dialog.computer.id })} + onConfirm={() => computers.forget(dialog.computer.id)} pendingLabel="Forgetting…" - title={`Forget ${dialog.computer.name || "this computer"}?`} + title={`Forget ${computerName(dialog.computer)}?`} /> ) : null} {dialog.kind === "invite" && deviceIntents ? : null} @@ -95,61 +90,43 @@ export function useRemoteSection({ deviceIntents, devices, phone }: RemoteSectio }; } -type DesktopComputers = { - computers: RemoteComputer[] | undefined; - /** Id of the computer being switched to; "" stands for this computer. */ +type SettingsComputers = { + snapshot: ComputersSnapshot | undefined; + /** Id of the computer being switched to; "" stands for this device. */ switching: string | undefined; + /** The pending switch can still be ended. */ + cancellable: boolean; failed: { id: string; message: string } | undefined; - loadError: string; - use(id?: string): Promise; + use(id?: string): void; + cancel(): void; forget(id: string): Promise; + openPairing(): Promise; }; /** - * The shell owns the paired computers and restarts to apply a switch, so a - * settled promise means nothing changed or the shell refused with a sentence. + * The shell owns the computers and applies a switch; the switch itself is the + * one the Task navigation shows too, so both agree on what is connecting. */ -function useRemoteComputers(capability: RemoteComputersCapability | undefined): DesktopComputers { - const [computers, setComputers] = useState(); - const [switching, setSwitching] = useState(); - const [failed, setFailed] = useState<{ id: string; message: string }>(); - const [loadError, setLoadError] = useState(""); +function useSettingsComputers(capability: ComputersCapability | undefined): SettingsComputers { + const snapshot = useComputers(capability); + const switcher = capability ? computerSwitch(capability) : undefined; + const switched = useComputerSwitch(switcher); + const [pairFailed, setPairFailed] = useState(); const mounted = useRef(true); - const refresh = async () => { - if (!capability) return; - try { - const next = await capability.list(); - if (mounted.current) { - setComputers(next); - setLoadError(""); - } - } catch { - if (mounted.current) setLoadError("OpenAIDE could not read its paired computers. Reopen Settings to try again."); - } - }; useEffect(() => { mounted.current = true; - void refresh(); return () => { mounted.current = false; }; - }, [capability]); + }, []); return { - computers, - switching, - failed, - loadError, - use: async (id) => { - if (!capability || switching !== undefined) return; - setFailed(undefined); - setSwitching(id ?? ""); - try { - await capability.use(id); - await refresh(); - } catch (cause) { - if (mounted.current) setFailed({ id: id ?? "", message: errorSentence(cause, "OpenAIDE could not switch. Try again.") }); - } finally { - if (mounted.current) setSwitching(undefined); - } + snapshot, + switching: switched.pending, + cancellable: switched.cancellable, + failed: pairFailed ? { id: "pair", message: pairFailed } : switched.failed, + use: (id) => { + setPairFailed(undefined); + void switcher?.request(id); }, + cancel: () => switcher?.cancel(), forget: async (id) => { if (!capability) return; try { @@ -157,121 +134,100 @@ function useRemoteComputers(capability: RemoteComputersCapability | undefined): } catch (cause) { throw new Error(errorSentence(cause, "OpenAIDE could not forget that computer. Try again.")); } - await refresh(); + }, + openPairing: async () => { + setPairFailed(undefined); + try { + await capability?.openPairing?.(); + } catch (cause) { + if (mounted.current) setPairFailed(errorSentence(cause, "Pairing could not open. Try again.")); + } }, }; } -function desktopComputersGroup( - desktop: DesktopComputers, +function computersGroup( + computers: SettingsComputers, + phone: PhoneConnection | undefined, onPair: () => void, - onForget: (computer: RemoteComputer) => void, + onForget: (computer: ShellComputer) => void, ): SettingsGroup { - const computers = desktop.computers ?? []; - const remote = computers.some((computer) => computer.current); - const busy = desktop.switching !== undefined; - const rows: SettingsRow[] = [{ - id: "this-computer", - label: "This computer", - description: "Projects, tools and Agents here", - keywords: "computer remote connect switch pc local", - leading: