| OLD | NEW |
| (Empty) |
| 1 // Copyright 2013 The Chromium Authors. All rights reserved. | |
| 2 // Use of this source code is governed by a BSD-style license that can be | |
| 3 // found in the LICENSE file. | |
| 4 | |
| 5 #include "win8/metro_driver/ime/text_service.h" | |
| 6 | |
| 7 #include <msctf.h> | |
| 8 #include <stddef.h> | |
| 9 #include <stdint.h> | |
| 10 | |
| 11 #include <utility> | |
| 12 | |
| 13 #include "base/logging.h" | |
| 14 #include "base/macros.h" | |
| 15 #include "base/win/scoped_variant.h" | |
| 16 #include "ui/metro_viewer/ime_types.h" | |
| 17 #include "win8/metro_driver/ime/text_service_delegate.h" | |
| 18 #include "win8/metro_driver/ime/text_store.h" | |
| 19 #include "win8/metro_driver/ime/text_store_delegate.h" | |
| 20 | |
| 21 // Architecture overview of input method support on Ash mode: | |
| 22 // | |
| 23 // Overview: | |
| 24 // On Ash mode, the system keyboard focus is owned by the metro_driver process | |
| 25 // while most of event handling are still implemented in the browser process. | |
| 26 // Thus the metro_driver basically works as a proxy that simply forwards | |
| 27 // keyevents to the metro_driver process. IME support must be involved somewhere | |
| 28 // in this flow. | |
| 29 // | |
| 30 // In short, we need to interact with an IME in the metro_driver process since | |
| 31 // TSF (Text Services Framework) runtime wants to processes keyevents while | |
| 32 // (and only while) the attached UI thread owns keyboard focus. | |
| 33 // | |
| 34 // Due to this limitation, we need to split IME handling into two parts, one | |
| 35 // is in the metro_driver process and the other is in the browser process. | |
| 36 // The metro_driver process is responsible for implementing the primary data | |
| 37 // store for the composition text and wiring it up with an IME via TSF APIs. | |
| 38 // On the other hand, the browser process is responsible for calculating | |
| 39 // character position in the composition text whenever the composition text | |
| 40 // is updated. | |
| 41 // | |
| 42 // IPC overview: | |
| 43 // Fortunately, we don't need so many IPC messages to support IMEs. In fact, | |
| 44 // only 4 messages are required to enable basic IME functionality. | |
| 45 // | |
| 46 // metro_driver process -> browser process | |
| 47 // Message Type: | |
| 48 // - MetroViewerHostMsg_ImeCompositionChanged | |
| 49 // - MetroViewerHostMsg_ImeTextCommitted | |
| 50 // Message Routing: | |
| 51 // TextServiceImpl | |
| 52 // -> ChromeAppViewAsh | |
| 53 // -- (process boundary) -- | |
| 54 // -> RemoteWindowTreeHostWin | |
| 55 // -> RemoteInputMethodWin | |
| 56 // | |
| 57 // browser process -> metro_driver process | |
| 58 // Message Type: | |
| 59 // - MetroViewerHostMsg_ImeCancelComposition | |
| 60 // - MetroViewerHostMsg_ImeTextInputClientUpdated | |
| 61 // Message Routing: | |
| 62 // RemoteInputMethodWin | |
| 63 // -> RemoteWindowTreeHostWin | |
| 64 // -- (process boundary) -- | |
| 65 // -> ChromeAppViewAsh | |
| 66 // -> TextServiceImpl | |
| 67 // | |
| 68 // Note that a keyevent may be forwarded through a different path. When a | |
| 69 // keyevent is not handled by an IME, such keyevent and subsequent character | |
| 70 // events will be sent from the metro_driver process to the browser process as | |
| 71 // following IPC messages. | |
| 72 // - MetroViewerHostMsg_KeyDown | |
| 73 // - MetroViewerHostMsg_KeyUp | |
| 74 // - MetroViewerHostMsg_Character | |
| 75 // | |
| 76 // How TextServiceImpl works: | |
| 77 // Here is the list of the major tasks that are handled in TextServiceImpl. | |
| 78 // - Manages a session object obtained from TSF runtime. We need them to call | |
| 79 // most of TSF APIs. | |
| 80 // - Handles OnDocumentChanged event. Whenever the document type is changed, | |
| 81 // TextServiceImpl destroyes the current document and initializes new one | |
| 82 // according to the given |input_scopes|. | |
| 83 // - Stores the |composition_character_bounds_| passed from OnDocumentChanged | |
| 84 // event so that an IME or on-screen keyboard can query the character | |
| 85 // position synchronously. | |
| 86 // The most complicated part is the OnDocumentChanged handler. Since some IMEs | |
| 87 // such as Japanese IMEs drastically change their behavior depending on | |
| 88 // properties exposed from the virtual document, we need to set up a lot | |
| 89 // properties carefully and correctly. See DocumentBinding class in this file | |
| 90 // about what will be involved in this multi-phase construction. See also | |
| 91 // text_store.cc and input_scope.cc for more underlying details. | |
| 92 | |
| 93 namespace metro_driver { | |
| 94 namespace { | |
| 95 | |
| 96 // Japanese IME expects the default value of this compartment is | |
| 97 // TF_SENTENCEMODE_PHRASEPREDICT to emulate IMM32 behavior. This value is | |
| 98 // managed per thread, thus setting this value at once is sufficient. This | |
| 99 // value never affects non-Japanese IMEs. | |
| 100 bool InitializeSentenceMode(ITfThreadMgr* thread_manager, | |
| 101 TfClientId client_id) { | |
| 102 base::win::ScopedComPtr<ITfCompartmentMgr> thread_compartment_manager; | |
| 103 HRESULT hr = thread_compartment_manager.QueryFrom(thread_manager); | |
| 104 if (FAILED(hr)) { | |
| 105 LOG(ERROR) << "QueryFrom failed. hr = " << hr; | |
| 106 return false; | |
| 107 } | |
| 108 base::win::ScopedComPtr<ITfCompartment> sentence_compartment; | |
| 109 hr = thread_compartment_manager->GetCompartment( | |
| 110 GUID_COMPARTMENT_KEYBOARD_INPUTMODE_SENTENCE, | |
| 111 sentence_compartment.Receive()); | |
| 112 if (FAILED(hr)) { | |
| 113 LOG(ERROR) << "ITfCompartment::GetCompartment failed. hr = " << hr; | |
| 114 return false; | |
| 115 } | |
| 116 | |
| 117 base::win::ScopedVariant sentence_variant; | |
| 118 sentence_variant.Set(TF_SENTENCEMODE_PHRASEPREDICT); | |
| 119 hr = sentence_compartment->SetValue(client_id, sentence_variant.ptr()); | |
| 120 if (FAILED(hr)) { | |
| 121 LOG(ERROR) << "ITfCompartment::SetValue failed. hr = " << hr; | |
| 122 return false; | |
| 123 } | |
| 124 return true; | |
| 125 } | |
| 126 | |
| 127 // Initializes |context| as disabled context where IMEs will be disabled. | |
| 128 bool InitializeDisabledContext(ITfContext* context, TfClientId client_id) { | |
| 129 base::win::ScopedComPtr<ITfCompartmentMgr> compartment_mgr; | |
| 130 HRESULT hr = compartment_mgr.QueryFrom(context); | |
| 131 if (FAILED(hr)) { | |
| 132 LOG(ERROR) << "QueryFrom failed. hr = " << hr; | |
| 133 return false; | |
| 134 } | |
| 135 | |
| 136 base::win::ScopedComPtr<ITfCompartment> disabled_compartment; | |
| 137 hr = compartment_mgr->GetCompartment(GUID_COMPARTMENT_KEYBOARD_DISABLED, | |
| 138 disabled_compartment.Receive()); | |
| 139 if (FAILED(hr)) { | |
| 140 LOG(ERROR) << "ITfCompartment::GetCompartment failed. hr = " << hr; | |
| 141 return false; | |
| 142 } | |
| 143 | |
| 144 base::win::ScopedVariant variant; | |
| 145 variant.Set(1); | |
| 146 hr = disabled_compartment->SetValue(client_id, variant.ptr()); | |
| 147 if (FAILED(hr)) { | |
| 148 LOG(ERROR) << "ITfCompartment::SetValue failed. hr = " << hr; | |
| 149 return false; | |
| 150 } | |
| 151 | |
| 152 base::win::ScopedComPtr<ITfCompartment> empty_context; | |
| 153 hr = compartment_mgr->GetCompartment(GUID_COMPARTMENT_EMPTYCONTEXT, | |
| 154 empty_context.Receive()); | |
| 155 if (FAILED(hr)) { | |
| 156 LOG(ERROR) << "ITfCompartment::GetCompartment failed. hr = " << hr; | |
| 157 return false; | |
| 158 } | |
| 159 | |
| 160 base::win::ScopedVariant empty_context_variant; | |
| 161 empty_context_variant.Set(static_cast<int32_t>(1)); | |
| 162 hr = empty_context->SetValue(client_id, empty_context_variant.ptr()); | |
| 163 if (FAILED(hr)) { | |
| 164 LOG(ERROR) << "ITfCompartment::SetValue failed. hr = " << hr; | |
| 165 return false; | |
| 166 } | |
| 167 | |
| 168 return true; | |
| 169 } | |
| 170 | |
| 171 bool IsPasswordField(const std::vector<InputScope>& input_scopes) { | |
| 172 return std::find(input_scopes.begin(), input_scopes.end(), IS_PASSWORD) != | |
| 173 input_scopes.end(); | |
| 174 } | |
| 175 | |
| 176 // A class that manages the lifetime of the event callback registration. When | |
| 177 // this object is destroyed, corresponding event callback will be unregistered. | |
| 178 class EventSink { | |
| 179 public: | |
| 180 EventSink(DWORD cookie, base::win::ScopedComPtr<ITfSource> source) | |
| 181 : cookie_(cookie), | |
| 182 source_(source) {} | |
| 183 ~EventSink() { | |
| 184 if (!source_.get() || cookie_ != TF_INVALID_COOKIE) | |
| 185 return; | |
| 186 source_->UnadviseSink(cookie_); | |
| 187 cookie_ = TF_INVALID_COOKIE; | |
| 188 source_.Release(); | |
| 189 } | |
| 190 | |
| 191 private: | |
| 192 DWORD cookie_; | |
| 193 base::win::ScopedComPtr<ITfSource> source_; | |
| 194 DISALLOW_COPY_AND_ASSIGN(EventSink); | |
| 195 }; | |
| 196 | |
| 197 scoped_ptr<EventSink> CreateTextEditSink(ITfContext* context, | |
| 198 ITfTextEditSink* text_store) { | |
| 199 DCHECK(text_store); | |
| 200 base::win::ScopedComPtr<ITfSource> source; | |
| 201 DWORD cookie = TF_INVALID_EDIT_COOKIE; | |
| 202 HRESULT hr = source.QueryFrom(context); | |
| 203 if (FAILED(hr)) { | |
| 204 LOG(ERROR) << "QueryFrom failed, hr = " << hr; | |
| 205 return scoped_ptr<EventSink>(); | |
| 206 } | |
| 207 hr = source->AdviseSink(IID_ITfTextEditSink, text_store, &cookie); | |
| 208 if (FAILED(hr)) { | |
| 209 LOG(ERROR) << "AdviseSink failed, hr = " << hr; | |
| 210 return scoped_ptr<EventSink>(); | |
| 211 } | |
| 212 return scoped_ptr<EventSink>(new EventSink(cookie, source)); | |
| 213 } | |
| 214 | |
| 215 // A set of objects that should have the same lifetime. Following things | |
| 216 // are maintained. | |
| 217 // - TextStore: a COM object that abstracts text buffer. This object is | |
| 218 // actually implemented by us in text_store.cc | |
| 219 // - ITfDocumentMgr: a focusable unit in TSF. This object is implemented by | |
| 220 // TSF runtime and works as a container of TextStore. | |
| 221 // - EventSink: an object that ensures that the event callback between | |
| 222 // TSF runtime and TextStore is unregistered when this object is destroyed. | |
| 223 class DocumentBinding { | |
| 224 public: | |
| 225 ~DocumentBinding() { | |
| 226 if (!document_manager_.get()) | |
| 227 return; | |
| 228 document_manager_->Pop(TF_POPF_ALL); | |
| 229 } | |
| 230 | |
| 231 static scoped_ptr<DocumentBinding> Create( | |
| 232 ITfThreadMgr* thread_manager, | |
| 233 TfClientId client_id, | |
| 234 const std::vector<InputScope>& input_scopes, | |
| 235 HWND window_handle, | |
| 236 TextStoreDelegate* delegate) { | |
| 237 base::win::ScopedComPtr<ITfDocumentMgr> document_manager; | |
| 238 HRESULT hr = thread_manager->CreateDocumentMgr(document_manager.Receive()); | |
| 239 if (FAILED(hr)) { | |
| 240 LOG(ERROR) << "ITfThreadMgr::CreateDocumentMgr failed. hr = " << hr; | |
| 241 return scoped_ptr<DocumentBinding>(); | |
| 242 } | |
| 243 | |
| 244 // Note: In our IPC protocol, an empty |input_scopes| is used to indicate | |
| 245 // that an IME must be disabled in this context. In such case, we need not | |
| 246 // instantiate TextStore. | |
| 247 const bool use_null_text_store = input_scopes.empty(); | |
| 248 | |
| 249 scoped_refptr<TextStore> text_store; | |
| 250 if (!use_null_text_store) { | |
| 251 text_store = TextStore::Create(window_handle, input_scopes, delegate); | |
| 252 if (!text_store.get()) { | |
| 253 LOG(ERROR) << "Failed to create TextStore."; | |
| 254 return scoped_ptr<DocumentBinding>(); | |
| 255 } | |
| 256 } | |
| 257 | |
| 258 base::win::ScopedComPtr<ITfContext> context; | |
| 259 DWORD edit_cookie = TF_INVALID_EDIT_COOKIE; | |
| 260 hr = document_manager->CreateContext( | |
| 261 client_id, | |
| 262 0, | |
| 263 static_cast<ITextStoreACP*>(text_store.get()), | |
| 264 context.Receive(), | |
| 265 &edit_cookie); | |
| 266 if (FAILED(hr)) { | |
| 267 LOG(ERROR) << "ITfDocumentMgr::CreateContext failed. hr = " << hr; | |
| 268 return scoped_ptr<DocumentBinding>(); | |
| 269 } | |
| 270 | |
| 271 // If null-TextStore is used or |input_scopes| looks like a password field, | |
| 272 // set special properties to tell IMEs to be disabled. | |
| 273 if ((use_null_text_store || IsPasswordField(input_scopes)) && | |
| 274 !InitializeDisabledContext(context.get(), client_id)) { | |
| 275 LOG(ERROR) << "InitializeDisabledContext failed."; | |
| 276 return scoped_ptr<DocumentBinding>(); | |
| 277 } | |
| 278 | |
| 279 scoped_ptr<EventSink> text_edit_sink; | |
| 280 if (!use_null_text_store) { | |
| 281 text_edit_sink = CreateTextEditSink(context.get(), text_store.get()); | |
| 282 if (!text_edit_sink) { | |
| 283 LOG(ERROR) << "CreateTextEditSink failed."; | |
| 284 return scoped_ptr<DocumentBinding>(); | |
| 285 } | |
| 286 } | |
| 287 hr = document_manager->Push(context.get()); | |
| 288 if (FAILED(hr)) { | |
| 289 LOG(ERROR) << "ITfDocumentMgr::Push failed. hr = " << hr; | |
| 290 return scoped_ptr<DocumentBinding>(); | |
| 291 } | |
| 292 return scoped_ptr<DocumentBinding>(new DocumentBinding( | |
| 293 text_store, document_manager, std::move(text_edit_sink))); | |
| 294 } | |
| 295 | |
| 296 ITfDocumentMgr* document_manager() const { return document_manager_.get(); } | |
| 297 | |
| 298 scoped_refptr<TextStore> text_store() const { | |
| 299 return text_store_; | |
| 300 } | |
| 301 | |
| 302 private: | |
| 303 DocumentBinding(scoped_refptr<TextStore> text_store, | |
| 304 base::win::ScopedComPtr<ITfDocumentMgr> document_manager, | |
| 305 scoped_ptr<EventSink> text_edit_sink) | |
| 306 : text_store_(text_store), | |
| 307 document_manager_(document_manager), | |
| 308 text_edit_sink_(std::move(text_edit_sink)) {} | |
| 309 | |
| 310 scoped_refptr<TextStore> text_store_; | |
| 311 base::win::ScopedComPtr<ITfDocumentMgr> document_manager_; | |
| 312 scoped_ptr<EventSink> text_edit_sink_; | |
| 313 | |
| 314 DISALLOW_COPY_AND_ASSIGN(DocumentBinding); | |
| 315 }; | |
| 316 | |
| 317 class TextServiceImpl : public TextService, | |
| 318 public TextStoreDelegate { | |
| 319 public: | |
| 320 TextServiceImpl(ITfThreadMgr* thread_manager, | |
| 321 TfClientId client_id, | |
| 322 HWND window_handle, | |
| 323 TextServiceDelegate* delegate) | |
| 324 : client_id_(client_id), | |
| 325 window_handle_(window_handle), | |
| 326 delegate_(delegate), | |
| 327 thread_manager_(thread_manager) { | |
| 328 DCHECK_NE(TF_CLIENTID_NULL, client_id); | |
| 329 DCHECK(window_handle != NULL); | |
| 330 DCHECK(thread_manager_.get()); | |
| 331 } | |
| 332 ~TextServiceImpl() override { | |
| 333 thread_manager_->Deactivate(); | |
| 334 } | |
| 335 | |
| 336 private: | |
| 337 // TextService overrides: | |
| 338 void CancelComposition() override { | |
| 339 if (!current_document_) { | |
| 340 VLOG(0) << "|current_document_| is NULL due to the previous error."; | |
| 341 return; | |
| 342 } | |
| 343 scoped_refptr<TextStore> text_store = current_document_->text_store(); | |
| 344 if (!text_store.get()) | |
| 345 return; | |
| 346 text_store->CancelComposition(); | |
| 347 } | |
| 348 | |
| 349 void OnDocumentChanged(const std::vector<int32_t>& input_scopes, | |
| 350 const std::vector<metro_viewer::CharacterBounds>& | |
| 351 character_bounds) override { | |
| 352 bool document_type_changed = input_scopes_ != input_scopes; | |
| 353 input_scopes_ = input_scopes; | |
| 354 composition_character_bounds_ = character_bounds; | |
| 355 if (document_type_changed) | |
| 356 OnDocumentTypeChanged(input_scopes); | |
| 357 } | |
| 358 | |
| 359 void OnWindowActivated() override { | |
| 360 if (!current_document_) { | |
| 361 VLOG(0) << "|current_document_| is NULL due to the previous error."; | |
| 362 return; | |
| 363 } | |
| 364 ITfDocumentMgr* document_manager = current_document_->document_manager(); | |
| 365 if (!document_manager) { | |
| 366 VLOG(0) << "|document_manager| is NULL due to the previous error."; | |
| 367 return; | |
| 368 } | |
| 369 HRESULT hr = thread_manager_->SetFocus(document_manager); | |
| 370 if (FAILED(hr)) { | |
| 371 LOG(ERROR) << "ITfThreadMgr::SetFocus failed. hr = " << hr; | |
| 372 return; | |
| 373 } | |
| 374 } | |
| 375 | |
| 376 void OnCompositionChanged( | |
| 377 const base::string16& text, | |
| 378 int32_t selection_start, | |
| 379 int32_t selection_end, | |
| 380 const std::vector<metro_viewer::UnderlineInfo>& underlines) override { | |
| 381 if (!delegate_) | |
| 382 return; | |
| 383 delegate_->OnCompositionChanged(text, | |
| 384 selection_start, | |
| 385 selection_end, | |
| 386 underlines); | |
| 387 } | |
| 388 | |
| 389 void OnTextCommitted(const base::string16& text) override { | |
| 390 if (!delegate_) | |
| 391 return; | |
| 392 delegate_->OnTextCommitted(text); | |
| 393 } | |
| 394 | |
| 395 RECT GetCaretBounds() override { | |
| 396 if (composition_character_bounds_.empty()) { | |
| 397 const RECT rect = {}; | |
| 398 return rect; | |
| 399 } | |
| 400 const metro_viewer::CharacterBounds& bounds = | |
| 401 composition_character_bounds_[0]; | |
| 402 POINT left_top = { bounds.left, bounds.top }; | |
| 403 POINT right_bottom = { bounds.right, bounds.bottom }; | |
| 404 ClientToScreen(window_handle_, &left_top); | |
| 405 ClientToScreen(window_handle_, &right_bottom); | |
| 406 const RECT rect = { | |
| 407 left_top.x, | |
| 408 left_top.y, | |
| 409 right_bottom.x, | |
| 410 right_bottom.y, | |
| 411 }; | |
| 412 return rect; | |
| 413 } | |
| 414 | |
| 415 bool GetCompositionCharacterBounds(uint32_t index, RECT* rect) override { | |
| 416 if (index >= composition_character_bounds_.size()) { | |
| 417 return false; | |
| 418 } | |
| 419 const metro_viewer::CharacterBounds& bounds = | |
| 420 composition_character_bounds_[index]; | |
| 421 POINT left_top = { bounds.left, bounds.top }; | |
| 422 POINT right_bottom = { bounds.right, bounds.bottom }; | |
| 423 ClientToScreen(window_handle_, &left_top); | |
| 424 ClientToScreen(window_handle_, &right_bottom); | |
| 425 SetRect(rect, left_top.x, left_top.y, right_bottom.x, right_bottom.y); | |
| 426 return true; | |
| 427 } | |
| 428 | |
| 429 void OnDocumentTypeChanged(const std::vector<int32_t>& input_scopes) { | |
| 430 std::vector<InputScope> native_input_scopes(input_scopes.size()); | |
| 431 for (size_t i = 0; i < input_scopes.size(); ++i) | |
| 432 native_input_scopes[i] = static_cast<InputScope>(input_scopes[i]); | |
| 433 scoped_ptr<DocumentBinding> new_document = | |
| 434 DocumentBinding::Create(thread_manager_.get(), | |
| 435 client_id_, | |
| 436 native_input_scopes, | |
| 437 window_handle_, | |
| 438 this); | |
| 439 LOG_IF(ERROR, !new_document) << "Failed to create a new document."; | |
| 440 current_document_.swap(new_document); | |
| 441 OnWindowActivated(); | |
| 442 } | |
| 443 | |
| 444 TfClientId client_id_; | |
| 445 HWND window_handle_; | |
| 446 TextServiceDelegate* delegate_; | |
| 447 scoped_ptr<DocumentBinding> current_document_; | |
| 448 base::win::ScopedComPtr<ITfThreadMgr> thread_manager_; | |
| 449 | |
| 450 // A vector of InputScope enumeration, which represents the document type of | |
| 451 // the focused text field. Note that in our IPC message protocol, an empty | |
| 452 // |input_scopes_| has special meaning that IMEs must be disabled on this | |
| 453 // document. | |
| 454 std::vector<int32_t> input_scopes_; | |
| 455 // Character bounds of the composition. When there is no composition but this | |
| 456 // vector is not empty, the first element contains the caret bounds. | |
| 457 std::vector<metro_viewer::CharacterBounds> composition_character_bounds_; | |
| 458 | |
| 459 DISALLOW_COPY_AND_ASSIGN(TextServiceImpl); | |
| 460 }; | |
| 461 | |
| 462 } // namespace | |
| 463 | |
| 464 scoped_ptr<TextService> | |
| 465 CreateTextService(TextServiceDelegate* delegate, HWND window_handle) { | |
| 466 if (!delegate) | |
| 467 return scoped_ptr<TextService>(); | |
| 468 base::win::ScopedComPtr<ITfThreadMgr> thread_manager; | |
| 469 HRESULT hr = thread_manager.CreateInstance(CLSID_TF_ThreadMgr); | |
| 470 if (FAILED(hr)) { | |
| 471 LOG(ERROR) << "Failed to create instance of CLSID_TF_ThreadMgr. hr = " | |
| 472 << hr; | |
| 473 return scoped_ptr<TextService>(); | |
| 474 } | |
| 475 TfClientId client_id = TF_CLIENTID_NULL; | |
| 476 hr = thread_manager->Activate(&client_id); | |
| 477 if (FAILED(hr)) { | |
| 478 LOG(ERROR) << "ITfThreadMgr::Activate failed. hr = " << hr; | |
| 479 return scoped_ptr<TextService>(); | |
| 480 } | |
| 481 if (!InitializeSentenceMode(thread_manager.get(), client_id)) { | |
| 482 LOG(ERROR) << "InitializeSentenceMode failed."; | |
| 483 thread_manager->Deactivate(); | |
| 484 return scoped_ptr<TextService>(); | |
| 485 } | |
| 486 return scoped_ptr<TextService>(new TextServiceImpl( | |
| 487 thread_manager.get(), client_id, window_handle, delegate)); | |
| 488 } | |
| 489 | |
| 490 } // namespace metro_driver | |
| OLD | NEW |