返回 DeepSeek-Reasonix
remote_tab_registry.go
根目录 / desktop / remote_tab_registry.go
1 package main
2
3 import (
4 "context"
5 "fmt"
6 "log"
7 "net/http"
8 "sort"
9 "strings"
10 "time"
11 )
12
13 // reconcileTabStripOrder merges the preferred persisted order with every
14 // currently live local and remote tab id.
15 func reconcileTabStripOrder(preferred, localIDs, remoteIDs []string) []string {
16 valid := make(map[string]bool, len(localIDs)+len(remoteIDs))
17 for _, id := range localIDs {
18 valid[id] = true
19 }
20 for _, id := range remoteIDs {
21 valid[id] = true
22 }
23 seen := make(map[string]bool, len(valid))
24 out := make([]string, 0, len(valid))
25 appendID := func(id string) {
26 if valid[id] && !seen[id] {
27 seen[id] = true
28 out = append(out, id)
29 }
30 }
31 for _, id := range preferred {
32 appendID(id)
33 }
34 for _, id := range localIDs {
35 appendID(id)
36 }
37 for _, id := range remoteIDs {
38 appendID(id)
39 }
40 return out
41 }
42
43 func (a *App) remoteTabMetas(localIDs []string) ([]TabMeta, string, []string) {
44 a.remoteTabMu.Lock()
45 defer a.remoteTabMu.Unlock()
46 ids := a.orderedRemoteTabIDsLocked()
47 metas := make([]TabMeta, 0, len(ids))
48 for _, id := range ids {
49 if tab := a.remoteTabs[id]; tab != nil {
50 meta := remoteTabMetaLocked(tab)
51 meta.Active = id == a.remoteTabLayout.activeID
52 metas = append(metas, meta)
53 }
54 }
55 a.remoteTabLayout.stripOrder = reconcileTabStripOrder(a.remoteTabLayout.stripOrder, localIDs, ids)
56 return metas, a.remoteTabLayout.activeID, append([]string(nil), a.remoteTabLayout.stripOrder...)
57 }
58
59 // orderedRemoteTabIDsLocked returns the remote strip order with self-repair:
60 // registry keys missing from the order append in sorted order (mirrors
61 // orderedTabIDsLocked for the local side). Caller holds remoteTabMu.
62 func (a *App) orderedRemoteTabIDsLocked() []string {
63 seen := make(map[string]bool, len(a.remoteTabLayout.order))
64 out := make([]string, 0, len(a.remoteTabs))
65 for _, id := range a.remoteTabLayout.order {
66 if a.remoteTabs[id] != nil && !seen[id] {
67 seen[id] = true
68 out = append(out, id)
69 }
70 }
71 var missing []string
72 for id := range a.remoteTabs {
73 if !seen[id] {
74 missing = append(missing, id)
75 }
76 }
77 sort.Strings(missing)
78 return append(out, missing...)
79 }
80
81 // remoteTabsFileEntries snapshots the persisted remote tab section (entries
82 // plus strip order plus the active remote id). Called from the tab-file write
83 // path — lock order tabsSaveMu → remoteTabMu.
84 func (a *App) remoteTabsFileEntries(localIDs []string) ([]desktopRemoteTabEntry, []string, []string, string) {
85 a.remoteTabMu.Lock()
86 defer a.remoteTabMu.Unlock()
87 ids := a.orderedRemoteTabIDsLocked()
88 entries := make([]desktopRemoteTabEntry, 0, len(ids))
89 for _, id := range ids {
90 tab := a.remoteTabs[id]
91 if tab == nil {
92 continue
93 }
94 entries = append(entries, desktopRemoteTabEntry{
95 ID: tab.id,
96 HostID: tab.ref.HostID,
97 Workspace: tab.ref.Workspace,
98 TopicTitle: tab.topicTitle,
99 Model: tab.model,
100 SessionName: tab.session.name,
101 SessionPath: tab.session.path,
102 SessionID: tab.session.sessionID,
103 SessionReset: tab.session.reset,
104 extra: cloneDesktopJSONFields(tab.persistenceExtra),
105 })
106 }
107 order := append([]string(nil), ids...)
108 if len(order) == 0 {
109 order = nil
110 }
111 stripOrder := reconcileTabStripOrder(a.remoteTabLayout.stripOrder, localIDs, ids)
112 if len(entries) == 0 {
113 stripOrder = nil
114 }
115 a.remoteTabLayout.stripOrder = append([]string(nil), stripOrder...)
116 return entries, order, stripOrder, a.remoteTabLayout.activeID
117 }
118
119 // CloseRemoteTab tears down one remote tab: the SSE pump stops and the
120 // registry entry goes away. The remote serve and the SSH connection stay
121 // untouched — other tabs on the same host keep running.
122 func (a *App) CloseRemoteTab(tabID string) error {
123 a.singleSurfaceMu.Lock()
124 defer a.singleSurfaceMu.Unlock()
125 return a.closeRemoteTabRegistration(tabID, false)
126 }
127
128 // removeRemoteTabsForHost drops surfaces whose connection identity was
129 // deleted. If that removes the final visible surface, create a local blank in
130 // the same single-surface transaction so workbench/creation layouts never
131 // retain an uncloseable orphan or become surface-less.
132 func (a *App) removeRemoteTabsForHost(hostID string) error {
133 a.singleSurfaceMu.Lock()
134 defer a.singleSurfaceMu.Unlock()
135
136 a.remoteTabMu.Lock()
137 ids := make([]string, 0, len(a.remoteTabs))
138 for id, tab := range a.remoteTabs {
139 if tab != nil && tab.ref.HostID == hostID {
140 ids = append(ids, id)
141 }
142 }
143 a.remoteTabMu.Unlock()
144 if len(ids) == 0 {
145 return nil
146 }
147 for _, id := range ids {
148 if err := a.closeRemoteTabRegistration(id, true); err != nil {
149 return err
150 }
151 }
152
153 a.mu.RLock()
154 localCount := len(a.tabs)
155 a.mu.RUnlock()
156 a.remoteTabMu.Lock()
157 remoteCount := len(a.remoteTabs)
158 a.remoteTabMu.Unlock()
159 if localCount+remoteCount > 0 {
160 return nil
161 }
162 _, err := a.ensureBlankTab("global", "")
163 return err
164 }
165
166 // closeRemoteTabRegistration performs the registry mutation. Callers that
167 // already hold singleSurfaceMu use allowEmpty only to roll back a tab whose
168 // open transaction failed before it became a usable surface.
169 func (a *App) closeRemoteTabRegistration(tabID string, allowEmpty bool) error {
170 publicationTab := a.lockRemoteTabPublication(tabID)
171 if publicationTab != nil {
172 defer publicationTab.routeEventMu.Unlock()
173 }
174 if !allowEmpty {
175 a.mu.RLock()
176 localCount := len(a.tabs)
177 a.remoteTabMu.Lock()
178 if localCount == 0 && len(a.remoteTabs) == 1 && a.remoteTabs[tabID] != nil {
179 a.remoteTabMu.Unlock()
180 a.mu.RUnlock()
181 return fmt.Errorf("cannot close the last tab")
182 }
183 a.mu.RUnlock()
184 } else {
185 a.remoteTabMu.Lock()
186 }
187 tab := a.remoteTabs[tabID]
188 if tab != publicationTab {
189 a.remoteTabMu.Unlock()
190 return nil
191 }
192 closingActive := a.remoteTabLayout.activeID == tabID
193 nextLocalID := ""
194 closingIndex := -1
195 for i, id := range a.remoteTabLayout.stripOrder {
196 if id == tabID {
197 closingIndex = i
198 break
199 }
200 }
201 delete(a.remoteTabs, tabID)
202 a.forgetRemoteBrowserExecutor(tabID)
203 a.remoteTabLayout.order = removeRemoteTabOrderID(a.remoteTabLayout.order, tabID)
204 if closingActive {
205 a.remoteTabLayout.activeID = ""
206 remaining := removeRemoteTabOrderID(append([]string(nil), a.remoteTabLayout.stripOrder...), tabID)
207 if len(remaining) > 0 && closingIndex >= 0 {
208 nextIndex := closingIndex
209 if nextIndex >= len(remaining) {
210 nextIndex = len(remaining) - 1
211 }
212 if nextID := remaining[nextIndex]; a.remoteTabs[nextID] != nil {
213 a.remoteTabLayout.activeID = nextID
214 } else {
215 nextLocalID = nextID
216 }
217 }
218 }
219 var cancel context.CancelFunc
220 if tab != nil {
221 cancel = tab.cancel
222 }
223 a.remoteTabMu.Unlock()
224 if cancel != nil {
225 cancel()
226 }
227 if closingActive && nextLocalID != "" {
228 a.mu.Lock()
229 if a.tabs[nextLocalID] != nil {
230 a.activeTabID = nextLocalID
231 }
232 a.mu.Unlock()
233 }
234 a.saveTabsFromRemote()
235 return nil
236 }
237
238 // remoteTabsHostStatus reacts to SSH transitions for every open tab on the
239 // host: losing the tunnel suspends the pumps, a regained connection
240 // re-attaches each tab to the still-running remote serve, and a terminal
241 // failure parks the tabs in error.
242 func (a *App) remoteTabsHostStatus(hostID, state, errText string) {
243 switch state {
244 case "connecting", "reconnecting":
245 a.suspendRemoteTabPumps(hostID, "reconnecting", "")
246 case "connected":
247 a.resumeRemoteTabs(hostID)
248 case "stopped":
249 a.suspendRemoteTabPumps(hostID, "error", errText)
250 }
251 }
252
253 func (a *App) remoteTabsForHost(hostID string) []*remoteTab {
254 a.remoteTabMu.Lock()
255 defer a.remoteTabMu.Unlock()
256 tabs := make([]*remoteTab, 0, 2)
257 for _, tab := range a.remoteTabs {
258 if tab.ref.HostID == hostID {
259 tabs = append(tabs, tab)
260 }
261 }
262 return tabs
263 }
264
265 func (a *App) suspendRemoteTabPumps(hostID, state, errText string) {
266 for _, tab := range a.remoteTabsForHost(hostID) {
267 tab.routeEventMu.Lock()
268 a.remoteTabMu.Lock()
269 if a.remoteTabs[tab.id] != tab || tab.ref.HostID != hostID || tab.state == "disconnected" || tab.state == "connecting" && tab.client == nil {
270 a.remoteTabMu.Unlock()
271 tab.routeEventMu.Unlock()
272 continue
273 }
274 tab.gen++
275 cancel := tab.cancel
276 tab.cancel = nil
277 tab.state, tab.err = state, errText
278 closeRemoteTabProvisionalRouteLocked(tab)
279 a.remoteTabMu.Unlock()
280 if cancel != nil {
281 cancel()
282 }
283 a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tab.id), RemoteTabStateView{State: state, Error: errText})
284 tab.routeEventMu.Unlock()
285 }
286 }
287
288 // parkRemoteTabsForServer intentionally retires pumps for one managed Serve.
289 // Cancelling generations before StopServer prevents their EOF path from
290 // interpreting an explicit stop as an unexpected disconnect and restarting it.
291 func (a *App) parkRemoteTabsForServer(hostID, workspace, state, errText string) []string {
292 affected := make([]string, 0, 2)
293 for _, tab := range a.remoteTabsForHost(hostID) {
294 tab.routeEventMu.Lock()
295 a.remoteTabMu.Lock()
296 if a.remoteTabs[tab.id] != tab || tab.ref.HostID != hostID || tab.ref.Workspace != workspace {
297 a.remoteTabMu.Unlock()
298 tab.routeEventMu.Unlock()
299 continue
300 }
301 tab.gen++
302 cancel := tab.cancel
303 tab.cancel, tab.client = nil, nil
304 tab.base, tab.token = "", ""
305 tab.state, tab.err = state, errText
306 closeRemoteTabProvisionalRouteLocked(tab)
307 affected = append(affected, tab.id)
308 a.remoteTabMu.Unlock()
309 if cancel != nil {
310 cancel()
311 }
312 a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tab.id), RemoteTabStateView{State: state, Error: errText})
313 tab.routeEventMu.Unlock()
314 }
315 return affected
316 }
317
318 // resumeRemoteTabs re-attaches every suspended tab of a reconnected host.
319 // The remote serve kept running through the SSH drop, so re-attachment only
320 // rebuilds the tunnel client and the event pump; the serve still holds the
321 // active session, so no session re-entry is needed. serve_down tabs re-arm
322 // first: their reattach exhausted while the tunnel was still healing, and a
323 // regained connection is the recovery signal they were waiting for.
324 func (a *App) resumeRemoteTabs(hostID string) {
325 a.remoteTabMu.Lock()
326 tabIDs := make([]string, 0, 2)
327 rearmed := make([]string, 0, 2)
328 for id, tab := range a.remoteTabs {
329 if tab.ref.HostID != hostID {
330 continue
331 }
332 switch tab.state {
333 case "reconnecting":
334 tabIDs = append(tabIDs, id)
335 case "serve_down":
336 tab.state, tab.err = "reconnecting", ""
337 tabIDs = append(tabIDs, id)
338 rearmed = append(rearmed, id)
339 }
340 }
341 a.remoteTabMu.Unlock()
342 for _, tabID := range rearmed {
343 a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tabID), RemoteTabStateView{State: "reconnecting"})
344 }
345 for _, tabID := range tabIDs {
346 a.goRemoteTabSafe("remoteTabReattach", func() { a.reattachRemoteTab(tabID) })
347 }
348 }
349
350 // The first EnsureServer after a tunnel drop races the SSH layer's own
351 // recovery; observed drops heal within a few seconds, so retries span that
352 // window instead of giving up after half a second and parking every tab that
353 // lost its stream mid-drop. Tests shrink this schedule.
354 var remoteTabReattachDelays = []time.Duration{
355 250 * time.Millisecond, 500 * time.Millisecond, time.Second,
356 2 * time.Second, 4 * time.Second, 8 * time.Second,
357 }
358
359 // reattachRemoteTab rebuilds one tab's serve client and pump after the host
360 // connection came back. Transient failures retry while the same tab remains
361 // reconnecting; exhaustion parks it in user-retryable serve_down until the
362 // next host recovery revives it.
363 func (a *App) reattachRemoteTab(tabID string) {
364 for i := 0; i <= len(remoteTabReattachDelays); i++ {
365 if i > 0 {
366 time.Sleep(remoteTabReattachDelays[i-1])
367 }
368 if a.reattachRemoteTabOnce(tabID) {
369 return
370 }
371 }
372 a.remoteTabMu.Lock()
373 tab := a.remoteTabs[tabID]
374 stillReconnecting := tab != nil && tab.state == "reconnecting"
375 a.remoteTabMu.Unlock()
376 if stillReconnecting {
377 a.emitRemoteTabState(tabID, "serve_down", "Remote session reconnect failed. Retry to restart the server.")
378 }
379 }
380
381 func (a *App) reattachRemoteTabOnce(tabID string) bool {
382 a.remoteTabMu.Lock()
383 tab := a.remoteTabs[tabID]
384 if tab == nil || tab.state != "reconnecting" {
385 a.remoteTabMu.Unlock()
386 return true
387 }
388 a.remoteTabMu.Unlock()
389 tab.sessionMu.Lock()
390 defer tab.sessionMu.Unlock()
391
392 a.remoteTabMu.Lock()
393 if a.remoteTabs[tabID] != tab || tab.state != "reconnecting" {
394 a.remoteTabMu.Unlock()
395 return true
396 }
397 hostID, workspace := tab.ref.HostID, tab.ref.Workspace
398 previousInstanceID := tab.session.instanceID
399 selection := snapshotRemoteTabReattachSelectionLocked(tab)
400 a.remoteTabMu.Unlock()
401
402 rt, err := a.remoteRT()
403 if err != nil {
404 return false
405 }
406 ctx := a.bootContext()
407 if ctx == nil {
408 ctx = context.Background()
409 }
410 view, token, err := rt.EnsureServer(ctx, hostID, workspace)
411 if err != nil || view.State != "ready" || view.LocalURL == "" {
412 // EnsureServer errors can include remote process output, including
413 // provider credentials forwarded during bootstrap. Keep reconnect
414 // diagnostics structural so secrets can never reach desktop logs.
415 log.Printf("[remote] reattachRemoteTab: EnsureServer NOT-READY tab=%s state=%s localURL=%q", tabID, view.State, view.LocalURL)
416 return false
417 }
418 callCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
419 defer cancel()
420 client, clientErr := newServeHTTPClient(view.LocalURL)
421 if clientErr != nil {
422 return false
423 }
424 capabilities, err := serveHandshakeCapabilities(callCtx, client, view.LocalURL, token)
425 if err != nil {
426 log.Printf("[remote] reattachRemoteTab: handshake FAILED tab=%s base=%q err=%v", tabID, view.LocalURL, err)
427 return false
428 }
429 relaunched := previousInstanceID != "" && view.InstanceID != "" && previousInstanceID != view.InstanceID
430 if relaunched && !selection.identified() {
431 // A replacement Serve starts on a blank controller. Publishing ready in
432 // that state would silently detach the tab from its conversation, so fail
433 // closed until the user explicitly chooses a session or New Topic.
434 log.Printf("[remote] reattachRemoteTab: replacement serve lacks session identity tab=%s", tabID)
435 return false
436 }
437
438 tab.routeEventMu.Lock()
439 a.remoteTabMu.Lock()
440 if cur := a.remoteTabs[tabID]; cur != tab || tab.state != "reconnecting" {
441 a.remoteTabMu.Unlock()
442 tab.routeEventMu.Unlock()
443 return true
444 }
445 tab.gen++
446 if tab.cancel != nil {
447 tab.cancel()
448 }
449 tab.client = client
450 tab.capabilities = make(map[string]bool, len(capabilities))
451 for _, capability := range capabilities {
452 tab.capabilities[capability] = true
453 }
454 tab.base = view.LocalURL
455 tab.token = token
456 gen := tab.gen
457 pathRevision := tab.routing.pathRevision
458 pumpCtx, cancelPump := context.WithCancel(ctx)
459 tab.cancel = cancelPump
460 a.remoteTabMu.Unlock()
461 tab.routeEventMu.Unlock()
462
463 opened := make(chan error, 1)
464 a.goRemoteTabSafe("remoteTabPump", func() { a.remoteTabPump(pumpCtx, tabID, gen, opened) })
465 select {
466 case err := <-opened:
467 if err != nil {
468 a.retireRemoteTabGeneration(tabID, gen)
469 a.emitRemoteTabState(tabID, "reconnecting", "")
470 return false
471 }
472 case <-callCtx.Done():
473 a.retireRemoteTabGeneration(tabID, gen)
474 a.emitRemoteTabState(tabID, "reconnecting", "")
475 return false
476 }
477 if selection.identified() && !a.reenterRemoteTabSelection(callCtx, tabID, tab, client, view.LocalURL, gen, pathRevision, relaunched, selection) {
478 a.retireRemoteTabGeneration(tabID, gen)
479 a.emitRemoteTabState(tabID, "reconnecting", "")
480 return false
481 }
482 if !a.waitRemoteTabStreamStable(callCtx, tabID, gen) {
483 return false
484 }
485 a.remoteTabMu.Lock()
486 if current := a.remoteTabs[tabID]; current == tab && current.gen == gen {
487 current.session.instanceID = view.InstanceID
488 }
489 a.remoteTabMu.Unlock()
490 if !a.transitionRemoteTabState(tabID, gen, "reconnecting", "ready", "") {
491 a.retireRemoteTabGeneration(tabID, gen)
492 return false
493 }
494 a.goRemoteTabSafe("remoteTabDeferredSelection", func() { a.applyPendingRemoteTabOpenSelection(tabID) })
495 return true
496 }
497
498 // remoteTabReattachSelection is the session a reattaching tab must land on,
499 // snapshotted before the network work starts so the re-entry decision cannot
500 // observe a selection committed mid-flight.
501 type remoteTabReattachSelection struct {
502 route string
503 name string
504 path string
505 sessionID string
506 // newSession marks a New Topic this tab never entered (its first pump died
507 // before /new was sent); reset marks a blank an earlier generation entered.
508 newSession bool
509 reset bool
510 }
511
512 func snapshotRemoteTabReattachSelectionLocked(tab *remoteTab) remoteTabReattachSelection {
513 return remoteTabReattachSelection{
514 route: strings.TrimSpace(tab.routing.currentPath), name: strings.TrimSpace(tab.session.name),
515 path: strings.TrimSpace(tab.session.path), sessionID: strings.TrimSpace(tab.session.sessionID),
516 newSession: tab.session.newSession, reset: tab.session.reset,
517 }
518 }
519
520 // identified reports whether the tab was opened for a particular session. A
521 // focus-only tab follows Serve's foreground and needs no re-entry.
522 func (s remoteTabReattachSelection) identified() bool {
523 return s.route != "" || s.name != "" || s.reset || s.newSession
524 }
525
526 // blank reports a selection that names no saved transcript. Re-entry then
527 // creates a fresh session: resuming a never-saved blank would fail.
528 func (s remoteTabReattachSelection) blank() bool {
529 return s.reset || s.route == "" && s.name == "" && s.newSession
530 }
531
532 func (s remoteTabReattachSelection) openOptions() RemoteTabOpenOptions {
533 if s.blank() {
534 return RemoteTabOpenOptions{NewSession: true}
535 }
536 return RemoteTabOpenOptions{SessionName: s.name, SessionPath: s.path, SessionID: s.sessionID}
537 }
538
539 // matchesServeForeground reports whether Serve still runs the selected
540 // session. An unsaved blank is absent from /sessions, so an empty foreground
541 // is consistent with a blank selection.
542 func (s remoteTabReattachSelection) matchesServeForeground(current serveSessionEntry) bool {
543 foreground := remoteSessionRoute(current)
544 if s.blank() {
545 return foreground == "" || foreground == s.route
546 }
547 if s.route != "" {
548 return foreground == s.route
549 }
550 return strings.TrimSpace(current.Name) == s.name
551 }
552
553 // reenterRemoteTabSelection lands a reattached pump on the session the tab was
554 // opened for. A replacement Serve always needs the transition. A surviving
555 // Serve is asked for its foreground first: another client may have moved it
556 // while this tab's stream was down, and publishing ready without re-entering
557 // would let the next /status silently adopt that foreign session.
558 func (a *App) reenterRemoteTabSelection(ctx context.Context, tabID string, tab *remoteTab, client *http.Client, base string, gen, pathRevision uint64, relaunched bool, selection remoteTabReattachSelection) bool {
559 if !relaunched {
560 current, err := serveCurrentSession(ctx, client, base)
561 if err != nil {
562 log.Printf("[remote] reattachRemoteTab: foreground probe FAILED tab=%s err=%v", tabID, err)
563 return false
564 }
565 if selection.matchesServeForeground(current) {
566 return true
567 }
568 }
569 opts := selection.openOptions()
570 target, err := enterRemoteSessionTarget(ctx, client, base, opts)
571 entered := err == nil && !target.TakenOver
572 switch {
573 case err == nil:
574 case remoteSessionTransitionBusy(err):
575 // Serve refuses transitions mid-turn but keeps a usable foreground.
576 // Follow it, as the first attach does, instead of parking the tab.
577 log.Printf("[remote] reattachRemoteTab: session re-entry BUSY (following current session) tab=%s err=%v", tabID, err)
578 if target, err = serveCurrentSession(ctx, client, base); err != nil {
579 return false
580 }
581 if remoteSessionRoute(target) == "" {
582 return true
583 }
584 case remoteSessionTakenOver(err):
585 log.Printf("[remote] reattachRemoteTab: session re-entry TAKEN OVER (read-only spectator) tab=%s err=%v", tabID, err)
586 target = serveSessionEntry{Name: selection.name, Path: selection.path, SessionID: selection.sessionID, TakenOver: true}
587 default:
588 log.Printf("[remote] reattachRemoteTab: session re-entry FAILED tab=%s err=%v", tabID, err)
589 return false
590 }
591 if !a.commitRemoteTabAttachResponse(tabID, tab, gen, pathRevision, target, opts.NewSession) {
592 return true
593 }
594 a.remoteTabMu.Lock()
595 current := a.remoteTabs[tabID]
596 if current != tab || current.gen != gen {
597 a.remoteTabMu.Unlock()
598 return true
599 }
600 if entered && opts.NewSession {
601 // Same blank contract as bootstrap: the fresh session is reusable by
602 // New Topic and carries the localized default title.
603 current.session.reset = true
604 current.topicTitle = a.localizedDefaultTopicTitle()
605 }
606 meta := remoteTabMetaLocked(current)
607 a.remoteTabMu.Unlock()
608 a.emitRemoteEvent("remote-tab:updated", meta)
609 a.saveTabsFromRemote()
610 return true
611 }
612
612 lines GO