VirtualBox

source: vbox/trunk/src/VBox/Main/src-server/USBProxyService.cpp@ 59125

Last change on this file since 59125 was 59117, checked in by vboxsync, 9 years ago

USB,Main: Rework USBProxyService. Split it into a USBProxyService and USBProxyBackend class, USBProxyService can use multiple USBProxyBackend instances as sources for USB devices to attach to a VM which will be used for USB/IP support. Change the PDM USB API to contain a backend parameter instead of a remote flag to indicate the USB backend to use for the given device.

  • Property svn:eol-style set to native
  • Property svn:keywords set to Author Date Id Revision
File size: 21.7 KB
Line 
1/* $Id: USBProxyService.cpp 59117 2015-12-14 14:04:37Z vboxsync $ */
2/** @file
3 * VirtualBox USB Proxy Service (base) class.
4 */
5
6/*
7 * Copyright (C) 2006-2014 Oracle Corporation
8 *
9 * This file is part of VirtualBox Open Source Edition (OSE), as
10 * available from http://www.215389.xyz. This file is free software;
11 * you can redistribute it and/or modify it under the terms of the GNU
12 * General Public License (GPL) as published by the Free Software
13 * Foundation, in version 2 as it comes in the "COPYING" file of the
14 * VirtualBox OSE distribution. VirtualBox OSE is distributed in the
15 * hope that it will be useful, but WITHOUT ANY WARRANTY of any kind.
16 */
17
18#include "USBProxyService.h"
19#include "HostUSBDeviceImpl.h"
20#include "HostImpl.h"
21#include "MachineImpl.h"
22#include "VirtualBoxImpl.h"
23
24#include "AutoCaller.h"
25#include "Logging.h"
26
27#include <VBox/com/array.h>
28#include <VBox/err.h>
29#include <iprt/asm.h>
30#include <iprt/semaphore.h>
31#include <iprt/thread.h>
32#include <iprt/mem.h>
33#include <iprt/string.h>
34
35/** Pair of a USB proxy backend and the opaque filter data assigned by the backend. */
36typedef std::pair<USBProxyBackend *, void *> USBFilterPair;
37/** List of USB filter pairs. */
38typedef std::list<USBFilterPair> USBFilterList;
39
40/**
41 * Data for a USB device filter.
42 */
43struct USBFilterData
44{
45 USBFilterData()
46 : llUsbFilters()
47 { }
48
49 USBFilterList llUsbFilters;
50};
51
52/**
53 * Initialize data members.
54 */
55USBProxyService::USBProxyService(Host *aHost)
56 : mHost(aHost), mDevices(), mBackends()
57{
58 LogFlowThisFunc(("aHost=%p\n", aHost));
59}
60
61
62/**
63 * Stub needed as long as the class isn't virtual
64 */
65HRESULT USBProxyService::init(void)
66{
67 USBProxyBackend *pUsbProxyBackendHost;
68# if defined(RT_OS_DARWIN)
69 pUsbProxyBackendHost = new USBProxyBackendDarwin(this);
70# elif defined(RT_OS_LINUX)
71 pUsbProxyBackendHost = new USBProxyBackendLinux(this);
72# elif defined(RT_OS_OS2)
73 pUsbProxyBackendHost = new USBProxyBackendOs2(this);
74# elif defined(RT_OS_SOLARIS)
75 pUsbProxyBackendHost = new USBProxyBackendSolaris(this);
76# elif defined(RT_OS_WINDOWS)
77 pUsbProxyBackendHost = new USBProxyBackendWindows(this);
78# elif defined(RT_OS_FREEBSD)
79 pUsbProxyBackendHost = new USBProxyBackendFreeBSD(this);
80# else
81 pUsbProxyBackendHost = new USBProxyBackend(this);
82# endif
83 int vrc = pUsbProxyBackendHost->init();
84 if (RT_FAILURE(vrc))
85 {
86 delete pUsbProxyBackendHost;
87 mLastError = vrc;
88 }
89 else
90 mBackends.push_back(pUsbProxyBackendHost);
91
92#if 0 /** @todo: Pass in the config. */
93 pUsbProxyBackendHost = new USBProxyBackendUsbIp(this);
94 hrc = pUsbProxyBackendHost->init();
95 if (FAILED(hrc))
96 {
97 delete pUsbProxyBackendHost;
98 return hrc;
99 }
100#endif
101
102 mBackends.push_back(pUsbProxyBackendHost);
103 return S_OK;
104}
105
106
107/**
108 * Empty destructor.
109 */
110USBProxyService::~USBProxyService()
111{
112 LogFlowThisFunc(("\n"));
113 mDevices.clear();
114 mBackends.clear();
115 mHost = NULL;
116}
117
118
119/**
120 * Query if the service is active and working.
121 *
122 * @returns true if the service is up running.
123 * @returns false if the service isn't running.
124 */
125bool USBProxyService::isActive(void)
126{
127 return mBackends.size() > 0;
128}
129
130
131/**
132 * Get last error.
133 * Can be used to check why the proxy !isActive() upon construction.
134 *
135 * @returns VBox status code.
136 */
137int USBProxyService::getLastError(void)
138{
139 return mLastError;
140}
141
142
143/**
144 * We're using the Host object lock.
145 *
146 * This is just a temporary measure until all the USB refactoring is
147 * done, probably... For now it help avoiding deadlocks we don't have
148 * time to fix.
149 *
150 * @returns Lock handle.
151 */
152RWLockHandle *USBProxyService::lockHandle() const
153{
154 return mHost->lockHandle();
155}
156
157
158void *USBProxyService::insertFilter(PCUSBFILTER aFilter)
159{
160 USBFilterData *pFilterData = new USBFilterData();
161
162 for (USBProxyBackendList::iterator it = mBackends.begin();
163 it != mBackends.end();
164 ++it)
165 {
166 USBProxyBackend *pUsbProxyBackend = *it;
167 void *pvId = pUsbProxyBackend->insertFilter(aFilter);
168
169 pFilterData->llUsbFilters.push_back(USBFilterPair(pUsbProxyBackend, pvId));
170 }
171
172 return pFilterData;
173}
174
175void USBProxyService::removeFilter(void *aId)
176{
177 USBFilterData *pFilterData = (USBFilterData *)aId;
178
179 for (USBFilterList::iterator it = pFilterData->llUsbFilters.begin();
180 it != pFilterData->llUsbFilters.end();
181 ++it)
182 {
183 USBProxyBackend *pUsbProxyBackend = it->first;
184 pUsbProxyBackend->removeFilter(it->second);
185 }
186
187 pFilterData->llUsbFilters.clear();
188 delete pFilterData;
189}
190
191/**
192 * Gets the collection of USB devices, slave of Host::USBDevices.
193 *
194 * This is an interface for the HostImpl::USBDevices property getter.
195 *
196 *
197 * @param aUSBDevices Where to store the pointer to the collection.
198 *
199 * @returns COM status code.
200 *
201 * @remarks The caller must own the write lock of the host object.
202 */
203HRESULT USBProxyService::getDeviceCollection(std::vector<ComPtr<IHostUSBDevice> > &aUSBDevices)
204{
205 AssertReturn(isWriteLockOnCurrentThread(), E_FAIL);
206
207 AutoWriteLock alock(this COMMA_LOCKVAL_SRC_POS);
208
209 aUSBDevices.resize(mDevices.size());
210 size_t i = 0;
211 for (HostUSBDeviceList::const_iterator it = mDevices.begin(); it != mDevices.end(); ++it, ++i)
212 aUSBDevices[i] = *it;
213
214 return S_OK;
215}
216
217
218/**
219 * Request capture of a specific device.
220 *
221 * This is in an interface for SessionMachine::CaptureUSBDevice(), which is
222 * an internal worker used by Console::AttachUSBDevice() from the VM process.
223 *
224 * When the request is completed, SessionMachine::onUSBDeviceAttach() will
225 * be called for the given machine object.
226 *
227 *
228 * @param aMachine The machine to attach the device to.
229 * @param aId The UUID of the USB device to capture and attach.
230 *
231 * @returns COM status code and error info.
232 *
233 * @remarks This method may operate synchronously as well as asynchronously. In the
234 * former case it will temporarily abandon locks because of IPC.
235 */
236HRESULT USBProxyService::captureDeviceForVM(SessionMachine *aMachine, IN_GUID aId, const com::Utf8Str &aCaptureFilename)
237{
238 ComAssertRet(aMachine, E_INVALIDARG);
239 AutoWriteLock alock(this COMMA_LOCKVAL_SRC_POS);
240
241 /*
242 * Translate the device id into a device object.
243 */
244 ComObjPtr<HostUSBDevice> pHostDevice = findDeviceById(aId);
245 if (pHostDevice.isNull())
246 return setError(E_INVALIDARG,
247 tr("The USB device with UUID {%RTuuid} is not currently attached to the host"), Guid(aId).raw());
248
249 /*
250 * Try to capture the device
251 */
252 alock.release();
253 return pHostDevice->i_requestCaptureForVM(aMachine, true /* aSetError */, aCaptureFilename);
254}
255
256
257/**
258 * Notification from VM process about USB device detaching progress.
259 *
260 * This is in an interface for SessionMachine::DetachUSBDevice(), which is
261 * an internal worker used by Console::DetachUSBDevice() from the VM process.
262 *
263 * @param aMachine The machine which is sending the notification.
264 * @param aId The UUID of the USB device is concerns.
265 * @param aDone \a false for the pre-action notification (necessary
266 * for advancing the device state to avoid confusing
267 * the guest).
268 * \a true for the post-action notification. The device
269 * will be subjected to all filters except those of
270 * of \a Machine.
271 *
272 * @returns COM status code.
273 *
274 * @remarks When \a aDone is \a true this method may end up doing IPC to other
275 * VMs when running filters. In these cases it will temporarily
276 * abandon its locks.
277 */
278HRESULT USBProxyService::detachDeviceFromVM(SessionMachine *aMachine, IN_GUID aId, bool aDone)
279{
280 LogFlowThisFunc(("aMachine=%p{%s} aId={%RTuuid} aDone=%RTbool\n",
281 aMachine,
282 aMachine->i_getName().c_str(),
283 Guid(aId).raw(),
284 aDone));
285
286 // get a list of all running machines while we're outside the lock
287 // (getOpenedMachines requests locks which are incompatible with the lock of the machines list)
288 SessionMachinesList llOpenedMachines;
289 mHost->i_parent()->i_getOpenedMachines(llOpenedMachines);
290
291 AutoWriteLock alock(this COMMA_LOCKVAL_SRC_POS);
292
293 ComObjPtr<HostUSBDevice> pHostDevice = findDeviceById(aId);
294 ComAssertRet(!pHostDevice.isNull(), E_FAIL);
295 AutoWriteLock devLock(pHostDevice COMMA_LOCKVAL_SRC_POS);
296
297 /*
298 * Work the state machine.
299 */
300 LogFlowThisFunc(("id={%RTuuid} state=%s aDone=%RTbool name={%s}\n",
301 pHostDevice->i_getId().raw(), pHostDevice->i_getStateName(), aDone, pHostDevice->i_getName().c_str()));
302 bool fRunFilters = false;
303 HRESULT hrc = pHostDevice->i_onDetachFromVM(aMachine, aDone, &fRunFilters);
304
305 /*
306 * Run filters if necessary.
307 */
308 if ( SUCCEEDED(hrc)
309 && fRunFilters)
310 {
311 USBProxyBackend *pUsbProxyBackend = pHostDevice->i_getUsbProxyBackend();
312 Assert(aDone && pHostDevice->i_getUnistate() == kHostUSBDeviceState_HeldByProxy && pHostDevice->i_getMachine().isNull());
313 devLock.release();
314 alock.release();
315 HRESULT hrc2 = pUsbProxyBackend->runAllFiltersOnDevice(pHostDevice, llOpenedMachines, aMachine);
316 ComAssertComRC(hrc2);
317 }
318 return hrc;
319}
320
321
322/**
323 * Apply filters for the machine to all eligible USB devices.
324 *
325 * This is in an interface for SessionMachine::CaptureUSBDevice(), which
326 * is an internal worker used by Console::AutoCaptureUSBDevices() from the
327 * VM process at VM startup.
328 *
329 * Matching devices will be attached to the VM and may result IPC back
330 * to the VM process via SessionMachine::onUSBDeviceAttach() depending
331 * on whether the device needs to be captured or not. If capture is
332 * required, SessionMachine::onUSBDeviceAttach() will be called
333 * asynchronously by the USB proxy service thread.
334 *
335 * @param aMachine The machine to capture devices for.
336 *
337 * @returns COM status code, perhaps with error info.
338 *
339 * @remarks Temporarily locks this object, the machine object and some USB
340 * device, and the called methods will lock similar objects.
341 */
342HRESULT USBProxyService::autoCaptureDevicesForVM(SessionMachine *aMachine)
343{
344 LogFlowThisFunc(("aMachine=%p{%s}\n",
345 aMachine,
346 aMachine->i_getName().c_str()));
347
348 /*
349 * Make a copy of the list because we cannot hold the lock protecting it.
350 * (This will not make copies of any HostUSBDevice objects, only reference them.)
351 */
352 AutoReadLock alock(this COMMA_LOCKVAL_SRC_POS);
353 HostUSBDeviceList ListCopy = mDevices;
354 alock.release();
355
356 for (HostUSBDeviceList::iterator it = ListCopy.begin();
357 it != ListCopy.end();
358 ++it)
359 {
360 ComObjPtr<HostUSBDevice> pHostDevice = *it;
361 AutoReadLock devLock(pHostDevice COMMA_LOCKVAL_SRC_POS);
362 if ( pHostDevice->i_getUnistate() == kHostUSBDeviceState_HeldByProxy
363 || pHostDevice->i_getUnistate() == kHostUSBDeviceState_Unused
364 || pHostDevice->i_getUnistate() == kHostUSBDeviceState_Capturable)
365 {
366 USBProxyBackend *pUsbProxyBackend = pHostDevice->i_getUsbProxyBackend();
367 devLock.release();
368 pUsbProxyBackend->runMachineFilters(aMachine, pHostDevice);
369 }
370 }
371
372 return S_OK;
373}
374
375
376/**
377 * Detach all USB devices currently attached to a VM.
378 *
379 * This is in an interface for SessionMachine::DetachAllUSBDevices(), which
380 * is an internal worker used by Console::powerDown() from the VM process
381 * at VM startup, and SessionMachine::uninit() at VM abend.
382 *
383 * This is, like #detachDeviceFromVM(), normally a two stage journey
384 * where \a aDone indicates where we are. In addition we may be called
385 * to clean up VMs that have abended, in which case there will be no
386 * preparatory call. Filters will be applied to the devices in the final
387 * call with the risk that we have to do some IPC when attaching them
388 * to other VMs.
389 *
390 * @param aMachine The machine to detach devices from.
391 *
392 * @returns COM status code, perhaps with error info.
393 *
394 * @remarks Write locks the host object and may temporarily abandon
395 * its locks to perform IPC.
396 */
397HRESULT USBProxyService::detachAllDevicesFromVM(SessionMachine *aMachine, bool aDone, bool aAbnormal)
398{
399 // get a list of all running machines while we're outside the lock
400 // (getOpenedMachines requests locks which are incompatible with the host object lock)
401 SessionMachinesList llOpenedMachines;
402 mHost->i_parent()->i_getOpenedMachines(llOpenedMachines);
403
404 AutoWriteLock alock(this COMMA_LOCKVAL_SRC_POS);
405
406 /*
407 * Make a copy of the device list (not the HostUSBDevice objects, just
408 * the list) since we may end up performing IPC and temporarily have
409 * to abandon locks when applying filters.
410 */
411 HostUSBDeviceList ListCopy = mDevices;
412
413 for (HostUSBDeviceList::iterator it = ListCopy.begin();
414 it != ListCopy.end();
415 ++it)
416 {
417 ComObjPtr<HostUSBDevice> pHostDevice = *it;
418 AutoWriteLock devLock(pHostDevice COMMA_LOCKVAL_SRC_POS);
419 if (pHostDevice->i_getMachine() == aMachine)
420 {
421 /*
422 * Same procedure as in detachUSBDevice().
423 */
424 bool fRunFilters = false;
425 HRESULT hrc = pHostDevice->i_onDetachFromVM(aMachine, aDone, &fRunFilters, aAbnormal);
426 if ( SUCCEEDED(hrc)
427 && fRunFilters)
428 {
429 USBProxyBackend *pUsbProxyBackend = pHostDevice->i_getUsbProxyBackend();
430 Assert( aDone
431 && pHostDevice->i_getUnistate() == kHostUSBDeviceState_HeldByProxy
432 && pHostDevice->i_getMachine().isNull());
433 devLock.release();
434 alock.release();
435 HRESULT hrc2 = pUsbProxyBackend->runAllFiltersOnDevice(pHostDevice, llOpenedMachines, aMachine);
436 ComAssertComRC(hrc2);
437 alock.acquire();
438 }
439 }
440 }
441
442 return S_OK;
443}
444
445
446// Internals
447/////////////////////////////////////////////////////////////////////////////
448
449
450/**
451 * Sort a list of USB devices.
452 *
453 * @returns Pointer to the head of the sorted doubly linked list.
454 * @param aDevices Head pointer (can be both singly and doubly linked list).
455 */
456static PUSBDEVICE sortDevices(PUSBDEVICE pDevices)
457{
458 PUSBDEVICE pHead = NULL;
459 PUSBDEVICE pTail = NULL;
460 while (pDevices)
461 {
462 /* unlink head */
463 PUSBDEVICE pDev = pDevices;
464 pDevices = pDev->pNext;
465 if (pDevices)
466 pDevices->pPrev = NULL;
467
468 /* find location. */
469 PUSBDEVICE pCur = pTail;
470 while ( pCur
471 && HostUSBDevice::i_compare(pCur, pDev) > 0)
472 pCur = pCur->pPrev;
473
474 /* insert (after pCur) */
475 pDev->pPrev = pCur;
476 if (pCur)
477 {
478 pDev->pNext = pCur->pNext;
479 pCur->pNext = pDev;
480 if (pDev->pNext)
481 pDev->pNext->pPrev = pDev;
482 else
483 pTail = pDev;
484 }
485 else
486 {
487 pDev->pNext = pHead;
488 if (pHead)
489 pHead->pPrev = pDev;
490 else
491 pTail = pDev;
492 pHead = pDev;
493 }
494 }
495
496 LogFlowFuncLeave();
497 return pHead;
498}
499
500
501/**
502 * Process any relevant changes in the attached USB devices.
503 *
504 * This is called from any available USB proxy backends service thread when they discover
505 * a change.
506 */
507void USBProxyService::i_updateDeviceList(USBProxyBackend *pUsbProxyBackend, PUSBDEVICE pDevices)
508{
509 LogFlowThisFunc(("\n"));
510
511 pDevices = sortDevices(pDevices);
512
513 // get a list of all running machines while we're outside the lock
514 // (getOpenedMachines requests higher priority locks)
515 SessionMachinesList llOpenedMachines;
516 mHost->i_parent()->i_getOpenedMachines(llOpenedMachines);
517
518 AutoWriteLock alock(this COMMA_LOCKVAL_SRC_POS);
519
520 /*
521 * Compare previous list with the new list of devices
522 * and merge in any changes while notifying Host.
523 */
524 HostUSBDeviceList::iterator it = this->mDevices.begin();
525 while ( it != mDevices.end()
526 || pDevices)
527 {
528 ComObjPtr<HostUSBDevice> pHostDevice;
529
530 if (it != mDevices.end())
531 pHostDevice = *it;
532
533 /*
534 * Assert that the object is still alive (we still reference it in
535 * the collection and we're the only one who calls uninit() on it.
536 */
537 AutoCaller devCaller(pHostDevice.isNull() ? NULL : pHostDevice);
538 AssertComRC(devCaller.rc());
539
540 /*
541 * Lock the device object since we will read/write its
542 * properties. All Host callbacks also imply the object is locked.
543 */
544 AutoWriteLock devLock(pHostDevice.isNull() ? NULL : pHostDevice
545 COMMA_LOCKVAL_SRC_POS);
546
547 /* Skip all devices not belonging to the same backend. */
548 if ( !pHostDevice.isNull()
549 && pHostDevice->i_getUsbProxyBackend() != pUsbProxyBackend)
550 {
551 ++it;
552 continue;
553 }
554
555 /*
556 * Compare.
557 */
558 int iDiff;
559 if (pHostDevice.isNull())
560 iDiff = 1;
561 else
562 {
563 if (!pDevices)
564 iDiff = -1;
565 else
566 iDiff = pHostDevice->i_compare(pDevices);
567 }
568 if (!iDiff)
569 {
570 /*
571 * The device still there, update the state and move on. The PUSBDEVICE
572 * structure is eaten by updateDeviceState / HostUSBDevice::updateState().
573 */
574 PUSBDEVICE pCur = pDevices;
575 pDevices = pDevices->pNext;
576 pCur->pPrev = pCur->pNext = NULL;
577
578 bool fRunFilters = false;
579 SessionMachine *pIgnoreMachine = NULL;
580 devLock.release();
581 alock.release();
582 if (pUsbProxyBackend->updateDeviceState(pHostDevice, pCur, &fRunFilters, &pIgnoreMachine))
583 pUsbProxyBackend->deviceChanged(pHostDevice,
584 (fRunFilters ? &llOpenedMachines : NULL),
585 pIgnoreMachine);
586 alock.acquire();
587 ++it;
588 }
589 else
590 {
591 if (iDiff > 0)
592 {
593 /*
594 * Head of pDevices was attached.
595 */
596 PUSBDEVICE pNew = pDevices;
597 pDevices = pDevices->pNext;
598 pNew->pPrev = pNew->pNext = NULL;
599
600 ComObjPtr<HostUSBDevice> NewObj;
601 NewObj.createObject();
602 NewObj->init(pNew, pUsbProxyBackend);
603 Log(("USBProxyService::processChanges: attached %p {%s} %s / %p:{.idVendor=%#06x, .idProduct=%#06x, .pszProduct=\"%s\", .pszManufacturer=\"%s\"}\n",
604 (HostUSBDevice *)NewObj,
605 NewObj->i_getName().c_str(),
606 NewObj->i_getStateName(),
607 pNew,
608 pNew->idVendor,
609 pNew->idProduct,
610 pNew->pszProduct,
611 pNew->pszManufacturer));
612
613 mDevices.insert(it, NewObj);
614
615 devLock.release();
616 alock.release();
617 pUsbProxyBackend->deviceAdded(NewObj, llOpenedMachines, pNew);
618 alock.acquire();
619 }
620 else
621 {
622 /*
623 * Check if the device was actually detached or logically detached
624 * as the result of a re-enumeration.
625 */
626 if (!pHostDevice->i_wasActuallyDetached())
627 ++it;
628 else
629 {
630 it = mDevices.erase(it);
631 devLock.release();
632 alock.release();
633 pUsbProxyBackend->deviceRemoved(pHostDevice);
634 Log(("USBProxyService::processChanges: detached %p {%s}\n",
635 (HostUSBDevice *)pHostDevice,
636 pHostDevice->i_getName().c_str()));
637
638 /* from now on, the object is no more valid,
639 * uninitialize to avoid abuse */
640 devCaller.release();
641 pHostDevice->uninit();
642 alock.acquire();
643 }
644 }
645 }
646 } /* while */
647
648 LogFlowThisFunc(("returns void\n"));
649}
650
651
652/**
653 * Returns the global USB filter list stored in the Host object.
654 *
655 * @returns nothing.
656 * @param pGlobalFilters Where to store the global filter list on success.
657 */
658void USBProxyService::i_getUSBFilters(USBDeviceFilterList *pGlobalFilters)
659{
660 mHost->i_getUSBFilters(pGlobalFilters);
661}
662
663
664/**
665 * Searches the list of devices (mDevices) for the given device.
666 *
667 *
668 * @returns Smart pointer to the device on success, NULL otherwise.
669 * @param aId The UUID of the device we're looking for.
670 */
671ComObjPtr<HostUSBDevice> USBProxyService::findDeviceById(IN_GUID aId)
672{
673 Guid Id(aId);
674 ComObjPtr<HostUSBDevice> Dev;
675 for (HostUSBDeviceList::iterator it = mDevices.begin();
676 it != mDevices.end();
677 ++it)
678 if ((*it)->i_getId() == Id)
679 {
680 Dev = (*it);
681 break;
682 }
683
684 return Dev;
685}
686
687/*static*/
688HRESULT USBProxyService::setError(HRESULT aResultCode, const char *aText, ...)
689{
690 va_list va;
691 va_start(va, aText);
692 HRESULT rc = VirtualBoxBase::setErrorInternal(aResultCode,
693 COM_IIDOF(IHost),
694 "USBProxyService",
695 Utf8StrFmt(aText, va),
696 false /* aWarning*/,
697 true /* aLogIt*/);
698 va_end(va);
699 return rc;
700}
701
702/* vi: set tabstop=4 shiftwidth=4 expandtab: */
Note: See TracBrowser for help on using the repository browser.

© 2025 Oracle Support Privacy / Do Not Sell My Info Terms of Use Trademark Policy Automated Access Etiquette