From 336f8f3117a4a4fe69fe8c66522ff0d88920fb49 Mon Sep 17 00:00:00 2001 From: Chad Scharf <3904944+cscharf@users.noreply.github.com> Date: Wed, 13 Jan 2021 17:08:33 -0500 Subject: [PATCH] Revert "Safari Web Extension Port from App Extension" --- gulpfile.js | 11 +- src/background/commands.background.ts | 2 +- src/background/main.background.ts | 43 +- src/background/runtime.background.ts | 32 +- src/browser/browserApi.ts | 124 +++- src/browser/safariApp.ts | 86 ++- src/content/autofill.js | 29 + src/content/autofiller.ts | 54 +- src/content/notificationBar.ts | 76 +- src/content/shortcuts.ts | 7 +- src/content/sso.ts | 9 + src/notification/bar.js | 47 +- src/popup/accounts/two-factor.component.ts | 3 +- src/popup/app-routing.animations.ts | 6 +- src/popup/components/pop-out.component.html | 2 +- src/popup/components/pop-out.component.ts | 3 +- src/popup/services/popup-utils.service.ts | 2 + src/popup/settings/options.component.html | 2 +- src/popup/settings/options.component.ts | 3 + src/popup/vault/current-tab.component.html | 2 +- src/popup/vault/current-tab.component.ts | 3 +- src/popup/vault/groupings.component.html | 2 +- src/popup/vault/groupings.component.ts | 3 +- src/safari/desktop.xcodeproj/project.pbxproj | 309 ++++---- .../contents.xcworkspacedata | 2 +- .../xcshareddata/xcschemes/desktop.xcscheme | 78 -- src/safari/desktop/AppDelegate.swift | 12 +- .../AccentColor.colorset/Contents.json | 11 - .../AppIcon.appiconset/Contents.json | 36 +- .../AppIcon.appiconset/icon128.png | Bin 5763 -> 0 bytes .../AppIcon.appiconset/icon16.png | Bin 3245 -> 0 bytes .../AppIcon.appiconset/icon32.png | Bin 3653 -> 0 bytes .../desktop/Assets.xcassets/Contents.json | 6 +- src/safari/desktop/Base.lproj/Main.storyboard | 684 ++++++++++++++++-- src/safari/desktop/Info.plist | 6 +- src/safari/desktop/ViewController.swift | 42 +- .../SafariExtensionViewController.xib | 20 + src/safari/safari/Info.plist | 64 +- .../safari/SafariExtensionHandler.swift | 101 +++ .../SafariExtensionViewController.swift | 440 +++++++++++ .../safari/SafariWebExtensionHandler.swift | 109 --- src/safari/safari/ToolbarItemIcon.pdf | Bin 0 -> 46066 bytes src/safari/safari/app/popup/index.html | 29 + src/services/browserMessaging.service.ts | 10 +- src/services/browserPlatformUtils.service.ts | 16 +- src/services/browserStorage.service.ts | 72 +- src/services/i18n.service.ts | 16 +- 47 files changed, 1950 insertions(+), 664 deletions(-) delete mode 100644 src/safari/desktop.xcodeproj/xcshareddata/xcschemes/desktop.xcscheme delete mode 100644 src/safari/desktop/Assets.xcassets/AccentColor.colorset/Contents.json delete mode 100644 src/safari/desktop/Assets.xcassets/AppIcon.appiconset/icon128.png delete mode 100644 src/safari/desktop/Assets.xcassets/AppIcon.appiconset/icon16.png delete mode 100644 src/safari/desktop/Assets.xcassets/AppIcon.appiconset/icon32.png create mode 100644 src/safari/safari/Base.lproj/SafariExtensionViewController.xib create mode 100644 src/safari/safari/SafariExtensionHandler.swift create mode 100644 src/safari/safari/SafariExtensionViewController.swift delete mode 100644 src/safari/safari/SafariWebExtensionHandler.swift create mode 100644 src/safari/safari/ToolbarItemIcon.pdf create mode 100644 src/safari/safari/app/popup/index.html diff --git a/gulpfile.js b/gulpfile.js index 7fc0146e9b..c90d6361ef 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -30,6 +30,13 @@ const filters = { safari: [ '!build/safari/**/*' ], + webExt: [ + '!build/manifest.json' + ], + nonSafariApp: [ + '!build/background.html', + '!build/popup/index.html' + ], }; function buildString() { @@ -179,7 +186,6 @@ function safariCopyAssets(source, dest) { .on('error', reject) .pipe(gulpif('safari/Info.plist', replace('0.0.1', manifest.version))) .pipe(gulpif('safari/Info.plist', replace('0.0.2', process.env.BUILD_NUMBER || manifest.version))) - .pipe(gulpif('desktop.xcodeproj/project.pbxproj', replace('../../../build', '../safari/app'))) .pipe(gulp.dest(dest)) .on('end', resolve); }); @@ -189,7 +195,8 @@ function safariCopyBuild(source, dest) { return new Promise((resolve, reject) => { gulp.src(source) .on('error', reject) - .pipe(filter(['**'].concat(filters.fonts))) + .pipe(filter(['**'].concat(filters.fonts) + .concat(filters.webExt).concat(filters.nonSafariApp))) .pipe(gulp.dest(dest)) .on('end', resolve); }); diff --git a/src/background/commands.background.ts b/src/background/commands.background.ts index 463862852a..625eb4cd67 100644 --- a/src/background/commands.background.ts +++ b/src/background/commands.background.ts @@ -20,7 +20,7 @@ export default class CommandsBackground { } async init() { - if (this.isVivaldi) { + if (this.isSafari || this.isVivaldi) { BrowserApi.messageListener('commands.background', async (msg: any, sender: any, sendResponse: any) => { if (msg.command === 'keyboardShortcutTriggered' && msg.shortcut) { await this.processCommand(msg.shortcut, sender); diff --git a/src/background/main.background.ts b/src/background/main.background.ts index 82d1f739a6..a929d52749 100644 --- a/src/background/main.background.ts +++ b/src/background/main.background.ts @@ -167,8 +167,8 @@ export default class MainBackground { return promise.then((result) => result.response === 'unlocked'); } }); - this.storageService = new BrowserStorageService(); - this.secureStorageService = new BrowserStorageService(); + this.storageService = new BrowserStorageService(this.platformUtilsService); + this.secureStorageService = new BrowserStorageService(this.platformUtilsService); this.i18nService = new I18nService(BrowserApi.getUILanguage(window)); this.cryptoFunctionService = new WebCryptoFunctionService(window, this.platformUtilsService); this.consoleLogService = new ConsoleLogService(false); @@ -252,18 +252,21 @@ export default class MainBackground { this.commandsBackground = new CommandsBackground(this, this.passwordGenerationService, this.platformUtilsService, this.analytics, this.vaultTimeoutService); - this.tabsBackground = new TabsBackground(this); - this.contextMenusBackground = new ContextMenusBackground(this, this.cipherService, - this.passwordGenerationService, this.analytics, this.platformUtilsService, this.vaultTimeoutService, - this.eventService, this.totpService); - this.idleBackground = new IdleBackground(this.vaultTimeoutService, this.storageService, - this.notificationsService); - this.webRequestBackground = new WebRequestBackground(this.platformUtilsService, this.cipherService, - this.vaultTimeoutService); - this.windowsBackground = new WindowsBackground(this); + if (!this.isSafari) { + this.tabsBackground = new TabsBackground(this); + this.contextMenusBackground = new ContextMenusBackground(this, this.cipherService, + this.passwordGenerationService, this.analytics, this.platformUtilsService, this.vaultTimeoutService, + this.eventService, this.totpService); + this.idleBackground = new IdleBackground(this.vaultTimeoutService, this.storageService, + this.notificationsService); + this.webRequestBackground = new WebRequestBackground(this.platformUtilsService, this.cipherService, + this.vaultTimeoutService); + this.windowsBackground = new WindowsBackground(this); + } } async bootstrap() { + SafariApp.init(); this.analytics.ga('send', 'pageview', '/background.html'); this.containerService.attachToWindow(window); @@ -273,11 +276,13 @@ export default class MainBackground { await this.runtimeBackground.init(); await this.commandsBackground.init(); - await this.tabsBackground.init(); - await this.contextMenusBackground.init(); - await this.idleBackground.init(); - await this.webRequestBackground.init(); - await this.windowsBackground.init(); + if (!this.isSafari) { + await this.tabsBackground.init(); + await this.contextMenusBackground.init(); + await this.idleBackground.init(); + await this.webRequestBackground.init(); + await this.windowsBackground.init(); + } return new Promise((resolve) => { setTimeout(async () => { @@ -292,7 +297,7 @@ export default class MainBackground { } async setIcon() { - if (!chrome.browserAction && !this.sidebarAction) { + if (this.isSafari || (!chrome.browserAction && !this.sidebarAction)) { return; } @@ -311,7 +316,7 @@ export default class MainBackground { } async refreshBadgeAndMenu(forLocked: boolean = false) { - if (!chrome.windows || !chrome.contextMenus) { + if (this.isSafari || !chrome.windows || !chrome.contextMenus) { return; } @@ -442,7 +447,7 @@ export default class MainBackground { } private async buildContextMenu() { - if (!chrome.contextMenus || this.buildingContextMenu) { + if (this.isSafari || !chrome.contextMenus || this.buildingContextMenu) { return; } diff --git a/src/background/runtime.background.ts b/src/background/runtime.background.ts index 685ffc1414..f08915352b 100644 --- a/src/background/runtime.background.ts +++ b/src/background/runtime.background.ts @@ -4,6 +4,7 @@ import { CipherView } from 'jslib/models/view/cipherView'; import { LoginUriView } from 'jslib/models/view/loginUriView'; import { LoginView } from 'jslib/models/view/loginView'; +import { AuthService } from 'jslib/abstractions/auth.service'; import { AutofillService } from '../services/abstractions/autofill.service'; import BrowserPlatformUtilsService from '../services/browserPlatformUtils.service'; import { CipherService } from 'jslib/abstractions/cipher.service'; @@ -12,7 +13,10 @@ import { EnvironmentService } from 'jslib/abstractions/environment.service'; import { I18nService } from 'jslib/abstractions/i18n.service'; import { NotificationsService } from 'jslib/abstractions/notifications.service'; import { PolicyService } from 'jslib/abstractions/policy.service'; +import { PopupUtilsService } from '../popup/services/popup-utils.service'; +import { StateService } from 'jslib/abstractions/state.service'; import { StorageService } from 'jslib/abstractions/storage.service'; +import { SyncService } from 'jslib/abstractions/sync.service'; import { SystemService } from 'jslib/abstractions/system.service'; import { UserService } from 'jslib/abstractions/user.service'; import { VaultTimeoutService } from 'jslib/abstractions/vaultTimeout.service'; @@ -20,6 +24,7 @@ import { VaultTimeoutService } from 'jslib/abstractions/vaultTimeout.service'; import { BrowserApi } from '../browser/browserApi'; import MainBackground from './main.background'; +import { NativeMessagingBackground } from './nativeMessaging.background'; import { Analytics } from 'jslib/misc'; import { Utils } from 'jslib/misc/utils'; @@ -31,6 +36,7 @@ export default class RuntimeBackground { private runtime: any; private autofillTimeout: any; private pageDetailsToAutoFill: any[] = []; + private isSafari: boolean; private onInstalledReason: string = null; constructor(private main: MainBackground, private autofillService: AutofillService, @@ -40,15 +46,19 @@ export default class RuntimeBackground { private systemService: SystemService, private vaultTimeoutService: VaultTimeoutService, private environmentService: EnvironmentService, private policyService: PolicyService, private userService: UserService) { + this.isSafari = this.platformUtilsService.isSafari(); + this.runtime = this.isSafari ? {} : chrome.runtime; // onInstalled listener must be wired up before anything else, so we do it in the ctor - chrome.runtime.onInstalled.addListener((details: any) => { - this.onInstalledReason = details.reason; - }); + if (!this.isSafari) { + this.runtime.onInstalled.addListener((details: any) => { + this.onInstalledReason = details.reason; + }); + } } async init() { - if (!chrome.runtime) { + if (!this.runtime) { return; } @@ -389,6 +399,20 @@ export default class RuntimeBackground { } private async checkOnInstalled() { + if (this.isSafari) { + const installedVersion = await this.storageService.get(ConstantsService.installedVersionKey); + if (installedVersion == null) { + this.onInstalledReason = 'install'; + } else if (BrowserApi.getApplicationVersion() !== installedVersion) { + this.onInstalledReason = 'update'; + } + + if (this.onInstalledReason != null) { + await this.storageService.save(ConstantsService.installedVersionKey, + BrowserApi.getApplicationVersion()); + } + } + setTimeout(async () => { if (this.onInstalledReason != null) { if (this.onInstalledReason === 'install') { diff --git a/src/browser/browserApi.ts b/src/browser/browserApi.ts index 6bfd977b61..1ee54a45b1 100644 --- a/src/browser/browserApi.ts +++ b/src/browser/browserApi.ts @@ -4,16 +4,20 @@ import { Utils } from 'jslib/misc/utils'; export class BrowserApi { static isWebExtensionsApi: boolean = (typeof browser !== 'undefined'); - static isSafariApi: boolean = navigator.userAgent.indexOf(' Safari/') !== -1; + static isSafariApi: boolean = (window as any).safariAppExtension === true; static isChromeApi: boolean = !BrowserApi.isSafariApi && (typeof chrome !== 'undefined'); static isFirefoxOnAndroid: boolean = navigator.userAgent.indexOf('Firefox/') !== -1 && navigator.userAgent.indexOf('Android') !== -1; static async getTabFromCurrentWindowId(): Promise { - return await BrowserApi.tabsQueryFirst({ - active: true, - windowId: chrome.windows.WINDOW_ID_CURRENT, - }); + if (BrowserApi.isChromeApi) { + return await BrowserApi.tabsQueryFirst({ + active: true, + windowId: chrome.windows.WINDOW_ID_CURRENT, + }); + } else if (BrowserApi.isSafariApi) { + return await BrowserApi.getTabFromCurrentWindow(); + } } static async getTabFromCurrentWindow(): Promise { @@ -30,11 +34,16 @@ export class BrowserApi { } static async tabsQuery(options: any): Promise { - return new Promise((resolve) => { - chrome.tabs.query(options, (tabs: any[]) => { - resolve(tabs); + if (BrowserApi.isChromeApi) { + return new Promise((resolve) => { + chrome.tabs.query(options, (tabs: any[]) => { + resolve(tabs); + }); }); - }); + } else if (BrowserApi.isSafariApi) { + const tabs = await SafariApp.sendMessageToApp('tabs_query', JSON.stringify(options)); + return tabs != null ? JSON.parse(tabs) : null; + } } static async tabsQueryFirst(options: any): Promise { @@ -63,36 +72,81 @@ export class BrowserApi { return; } - return new Promise((resolve) => { - chrome.tabs.sendMessage(tab.id, obj, options, () => { - if (chrome.runtime.lastError) { - // Some error happened - } - resolve(); + if (BrowserApi.isChromeApi) { + return new Promise((resolve) => { + chrome.tabs.sendMessage(tab.id, obj, options, () => { + if (chrome.runtime.lastError) { + // Some error happened + } + resolve(); + }); }); - }); + } else if (BrowserApi.isSafariApi) { + if (options != null && options.frameId != null && obj.bitwardenFrameId == null) { + obj.bitwardenFrameId = options.frameId; + } + await SafariApp.sendMessageToApp('tabs_message', JSON.stringify({ + tab: tab, + obj: JSON.stringify(obj), + options: options, + }), true); + } } static getBackgroundPage(): any { - return chrome.extension.getBackgroundPage(); + if (BrowserApi.isChromeApi) { + return chrome.extension.getBackgroundPage(); + } else if (BrowserApi.isSafariApi) { + return window; + } else { + return null; + } } static getApplicationVersion(): string { - return chrome.runtime.getManifest().version; + if (BrowserApi.isChromeApi) { + return chrome.runtime.getManifest().version; + } else if (BrowserApi.isSafariApi) { + return (window as any).bitwardenApplicationVersion; + } else { + return null; + } } static async isPopupOpen(): Promise { - return Promise.resolve(chrome.extension.getViews({ type: 'popup' }).length > 0); + if (BrowserApi.isChromeApi) { + return Promise.resolve(chrome.extension.getViews({ type: 'popup' }).length > 0); + } else if (BrowserApi.isSafariApi) { + const open = await SafariApp.sendMessageToApp('isPopoverOpen'); + return open === 'true'; + } else { + return Promise.resolve(false); + } } static createNewTab(url: string, extensionPage: boolean = false) { - chrome.tabs.create({ url: url }); + if (BrowserApi.isChromeApi) { + chrome.tabs.create({ url: url }); + } else if (BrowserApi.isSafariApi) { + SafariApp.sendMessageToApp('createNewTab', url, true); + } } static messageListener(name: string, callback: (message: any, sender: any, response: any) => void) { - chrome.runtime.onMessage.addListener((msg: any, sender: any, response: any) => { - callback(msg, sender, response); - }); + if (BrowserApi.isChromeApi) { + chrome.runtime.onMessage.addListener((msg: any, sender: any, response: any) => { + callback(msg, sender, response); + }); + } else if (BrowserApi.isSafariApi) { + SafariApp.addMessageListener(name, (message: any, sender: any, response: any) => { + if (message.bitwardenFrameId != null) { + if (sender != null && typeof (sender) === 'object' && sender.frameId == null) { + sender.frameId = message.bitwardenFrameId; + } + } + callback(message, sender, response); + }); + } } static closePopup(win: Window) { @@ -101,8 +155,10 @@ export class BrowserApi { // condition is only called if the popup wasn't already dismissed (future proofing). // ref: https://bugzilla.mozilla.org/show_bug.cgi?id=1433604 browser.tabs.update({ active: true }).finally(win.close); - } else { + } else if (BrowserApi.isWebExtensionsApi || BrowserApi.isChromeApi) { win.close(); + } else if (BrowserApi.isSafariApi) { + SafariApp.sendMessageToApp('hidePopover'); } } @@ -140,22 +196,30 @@ export class BrowserApi { } static getUILanguage(win: Window) { - return chrome.i18n.getUILanguage(); + if (BrowserApi.isSafariApi) { + return win.navigator.language; + } else { + return chrome.i18n.getUILanguage(); + } } static reloadExtension(win: Window) { if (win != null) { return win.location.reload(true); - } else { + } else if (BrowserApi.isSafariApi) { + SafariApp.sendMessageToApp('reloadExtension'); + } else if (!BrowserApi.isSafariApi) { return chrome.runtime.reload(); } } static reloadOpenWindows() { - const views = chrome.extension.getViews() as Window[]; - views.filter((w) => w.location.href != null).forEach((w) => { - w.location.reload(); - }); + if (!BrowserApi.isSafariApi) { + const views = chrome.extension.getViews() as Window[]; + views.filter((w) => w.location.href != null).forEach((w) => { + w.location.reload(); + }); + } } static connectNative(application: string): browser.runtime.Port | chrome.runtime.Port { diff --git a/src/browser/safariApp.ts b/src/browser/safariApp.ts index b53d3754b7..894ae2c6d4 100644 --- a/src/browser/safariApp.ts +++ b/src/browser/safariApp.ts @@ -1,6 +1,23 @@ import { BrowserApi } from './browserApi'; export class SafariApp { + static init() { + if ((window as any).bitwardenSafariAppInited) { + return; + } + (window as any).bitwardenSafariAppInited = true; + if (BrowserApi.isSafariApi) { + (window as any).bitwardenSafariAppRequests = + new Map void, timeoutDate: Date }>(); + (window as any).bitwardenSafariAppMessageListeners = + new Map void>(); + (window as any).bitwardenSafariAppMessageReceiver = (message: any) => { + SafariApp.receiveMessageFromApp(message); + }; + setInterval(() => SafariApp.cleanupOldRequests(), 5 * 60000); // every 5 mins + } + } + static sendMessageToApp(command: string, data: any = null, resolveNow = false): Promise { if (!BrowserApi.isSafariApi) { return Promise.resolve(null); @@ -8,14 +25,69 @@ export class SafariApp { return new Promise((resolve) => { const now = new Date(); const messageId = now.getTime().toString() + '_' + Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); - (browser as any).runtime.sendNativeMessage('com.bitwarden.desktop', { - id: messageId, - command: command, - data: data, - responseData: null, - }, (response: any) => { - resolve(response); + if (typeof safari === typeof undefined) { + (window as any).webkit.messageHandlers.bitwardenApp.postMessage(JSON.stringify({ + id: messageId, + command: command, + data: data, + responseData: null, + })); + } else { + safari.extension.dispatchMessage('bitwarden', { + command: command, + data: data, + responseData: null, + }); + } + if (resolveNow) { + resolve(); + } else { + (window as any).bitwardenSafariAppRequests.set(messageId, { + resolve: resolve, + timeoutDate: new Date(now.getTime() + 5 * 60000), + }); + } + }); + } + + static addMessageListener(name: string, callback: (message: any, sender: any, response: any) => void) { + (window as any).bitwardenSafariAppMessageListeners.set(name, callback); + } + + static sendMessageToListeners(message: any, sender: any, response: any) { + (window as any).bitwardenSafariAppMessageListeners.forEach((f: any) => f(message, sender, response)); + } + + private static receiveMessageFromApp(message: any) { + if (message == null) { + return; + } + if ((message.id == null || message.id === '') && message.command === 'app_message') { + try { + const msg = JSON.parse(message.data); + SafariApp.sendMessageToListeners(msg, { + id: 'app_message', + tab: message.senderTab, + }, null); + } catch { } + } else if (message.id != null && (window as any).bitwardenSafariAppRequests.has(message.id)) { + const p = (window as any).bitwardenSafariAppRequests.get(message.id); + p.resolve(message.responseData); + (window as any).bitwardenSafariAppRequests.delete(message.id); + } + } + + private static cleanupOldRequests() { + const removeIds: string[] = []; + ((window as any).bitwardenSafariAppRequests as + Map void, timeoutDate: Date }>) + .forEach((v, key) => { + if (v.timeoutDate < new Date()) { + removeIds.push(key); + } }); + removeIds.forEach((id) => { + (window as any).bitwardenSafariAppRequests.delete(id); }); } } diff --git a/src/content/autofill.js b/src/content/autofill.js index 956708fe3e..ca6c75ae08 100644 --- a/src/content/autofill.js +++ b/src/content/autofill.js @@ -989,6 +989,35 @@ End 1Password Extension */ + if ((typeof safari !== 'undefined') && navigator.userAgent.indexOf(' Safari/') !== -1 && + navigator.userAgent.indexOf('Chrome') === -1) { + if (window.__bitwardenFrameId == null) { + window.__bitwardenFrameId = Math.floor(Math.random() * Math.floor(99999999)); + } + safari.self.addEventListener('message', function (msgEvent) { + var msg = JSON.parse(msgEvent.message.msg); + if (msg.bitwardenFrameId != null && window.__bitwardenFrameId !== msg.bitwardenFrameId) { + return; + } + + if (msg.command === 'collectPageDetails') { + var pageDetails = collect(document); + var pageDetailsObj = JSON.parse(pageDetails); + safari.extension.dispatchMessage('bitwarden', { + command: 'collectPageDetailsResponse', + tab: msg.tab, + details: pageDetailsObj, + sender: msg.sender, + bitwardenFrameId: window.__bitwardenFrameId + }); + } + else if (msg.command === 'fillForm') { + fill(document, msg.fillScript); + } + }, false); + return; + } + chrome.runtime.onMessage.addListener(function (msg, sender, sendResponse) { if (msg.command === 'collectPageDetails') { var pageDetails = collect(document); diff --git a/src/content/autofiller.ts b/src/content/autofiller.ts index 1d756b8451..253c82603c 100644 --- a/src/content/autofiller.ts +++ b/src/content/autofiller.ts @@ -3,17 +3,44 @@ document.addEventListener('DOMContentLoaded', (event) => { let filledThisHref = false; let delayFillTimeout: number; - const enabledKey = 'enableAutoFillOnPageLoad'; - chrome.storage.local.get(enabledKey, (obj: any) => { - if (obj != null && obj[enabledKey] === true) { - setInterval(() => doFillIfNeeded(), 500); + const isSafari = (typeof safari !== 'undefined') && navigator.userAgent.indexOf(' Safari/') !== -1 && + navigator.userAgent.indexOf('Chrome') === -1; + + if (isSafari) { + if ((window as any).__bitwardenFrameId == null) { + (window as any).__bitwardenFrameId = Math.floor(Math.random() * Math.floor(99999999)); } - }); - chrome.runtime.onMessage.addListener((msg: any, sender: any, sendResponse: Function) => { - if (msg.command === 'fillForm' && pageHref === msg.url) { - filledThisHref = true; - } - }); + const responseCommand = 'autofillerAutofillOnPageLoadEnabledResponse'; + safari.extension.dispatchMessage('bitwarden', { + command: 'bgGetDataForTab', + responseCommand: responseCommand, + bitwardenFrameId: (window as any).__bitwardenFrameId, + }); + safari.self.addEventListener('message', (msgEvent: any) => { + const msg = JSON.parse(msgEvent.message.msg); + if (msg.bitwardenFrameId != null && (window as any).__bitwardenFrameId !== msg.bitwardenFrameId) { + return; + } + if (msg.command === responseCommand && msg.data.autofillEnabled === true) { + setInterval(() => doFillIfNeeded(), 500); + } else if (msg.command === 'fillForm' && pageHref === msg.url) { + filledThisHref = true; + } + }, false); + return; + } else { + const enabledKey = 'enableAutoFillOnPageLoad'; + chrome.storage.local.get(enabledKey, (obj: any) => { + if (obj != null && obj[enabledKey] === true) { + setInterval(() => doFillIfNeeded(), 500); + } + }); + chrome.runtime.onMessage.addListener((msg: any, sender: any, sendResponse: Function) => { + if (msg.command === 'fillForm' && pageHref === msg.url) { + filledThisHref = true; + } + }); + } function doFillIfNeeded(force: boolean = false) { if (force || pageHref !== window.location.href) { @@ -37,7 +64,12 @@ document.addEventListener('DOMContentLoaded', (event) => { sender: 'autofiller', }; - chrome.runtime.sendMessage(msg); + if (isSafari) { + msg.bitwardenFrameId = (window as any).__bitwardenFrameId; + safari.extension.dispatchMessage('bitwarden', msg); + } else { + chrome.runtime.sendMessage(msg); + } } } }); diff --git a/src/content/notificationBar.ts b/src/content/notificationBar.ts index 70fbd5e413..4974f53ad8 100644 --- a/src/content/notificationBar.ts +++ b/src/content/notificationBar.ts @@ -17,30 +17,71 @@ document.addEventListener('DOMContentLoaded', (event) => { const logInButtonNames = new Set(['log in', 'sign in', 'login', 'go', 'submit', 'continue', 'next']); const changePasswordButtonNames = new Set(['save password', 'update password', 'change password', 'change']); const changePasswordButtonContainsNames = new Set(['pass', 'change', 'contras', 'senha']); + let notificationBarData = null; + const isSafari = (typeof safari !== 'undefined') && navigator.userAgent.indexOf(' Safari/') !== -1 && + navigator.userAgent.indexOf('Chrome') === -1; let disabledAddLoginNotification = false; let disabledChangedPasswordNotification = false; - chrome.storage.local.get('neverDomains', (ndObj: any) => { - const domains = ndObj.neverDomains; - if (domains != null && domains.hasOwnProperty(window.location.hostname)) { + if (isSafari) { + if ((window as any).__bitwardenFrameId == null) { + (window as any).__bitwardenFrameId = Math.floor(Math.random() * Math.floor(99999999)); + } + if (inIframe) { return; } - chrome.storage.local.get('disableAddLoginNotification', (disAddObj: any) => { - disabledAddLoginNotification = disAddObj != null && disAddObj.disableAddLoginNotification === true; - chrome.storage.local.get('disableChangedPasswordNotification', (disChangedObj: any) => { - disabledChangedPasswordNotification = disChangedObj != null && - disChangedObj.disableChangedPasswordNotification === true; + const responseCommand = 'notificationBarDataResponse'; + safari.extension.dispatchMessage('bitwarden', { + command: 'bgGetDataForTab', + responseCommand: responseCommand, + bitwardenFrameId: (window as any).__bitwardenFrameId, + }); + safari.self.addEventListener('message', (msgEvent: any) => { + const msg = JSON.parse(msgEvent.message.msg); + if (msg.bitwardenFrameId != null && (window as any).__bitwardenFrameId !== msg.bitwardenFrameId) { + return; + } + if (msg.command === responseCommand && msg.data) { + notificationBarData = msg.data; + if (notificationBarData.neverDomains && + notificationBarData.neverDomains.hasOwnProperty(window.location.hostname)) { + return; + } + + disabledAddLoginNotification = notificationBarData.disabledAddLoginNotification === true; + disabledChangedPasswordNotification = notificationBarData.disabledChangedPasswordNotification === true; if (!disabledAddLoginNotification || !disabledChangedPasswordNotification) { collectIfNeededWithTimeout(); } + } + + processMessages(msg, () => { /* do nothing on send response for Safari */ }); + }, false); + return; + } else { + chrome.storage.local.get('neverDomains', (ndObj: any) => { + const domains = ndObj.neverDomains; + if (domains != null && domains.hasOwnProperty(window.location.hostname)) { + return; + } + + chrome.storage.local.get('disableAddLoginNotification', (disAddObj: any) => { + disabledAddLoginNotification = disAddObj != null && disAddObj.disableAddLoginNotification === true; + chrome.storage.local.get('disableChangedPasswordNotification', (disChangedObj: any) => { + disabledChangedPasswordNotification = disChangedObj != null && + disChangedObj.disableChangedPasswordNotification === true; + if (!disabledAddLoginNotification || !disabledChangedPasswordNotification) { + collectIfNeededWithTimeout(); + } + }); }); }); - }); - chrome.runtime.onMessage.addListener((msg: any, sender: any, sendResponse: Function) => { - processMessages(msg, sendResponse); - }); + chrome.runtime.onMessage.addListener((msg: any, sender: any, sendResponse: Function) => { + processMessages(msg, sendResponse); + }); + } function processMessages(msg: any, sendResponse: Function) { if (msg.command === 'openNotificationBar') { @@ -429,7 +470,7 @@ document.addEventListener('DOMContentLoaded', (event) => { } function closeExistingAndOpenBar(type: string, typeData: any) { - let barPage = 'notification/bar.html'; + let barPage = (isSafari ? 'app/' : '') + 'notification/bar.html'; switch (type) { case 'info': barPage = barPage + '?info=' + typeData.text; @@ -469,7 +510,7 @@ document.addEventListener('DOMContentLoaded', (event) => { return; } - const barPageUrl: string = chrome.extension.getURL(barPage); + const barPageUrl: string = isSafari ? (safari.extension.baseURI + barPage) : chrome.extension.getURL(barPage); const iframe = document.createElement('iframe'); iframe.style.cssText = 'height: 42px; width: 100%; border: 0; min-height: initial;'; @@ -539,6 +580,11 @@ document.addEventListener('DOMContentLoaded', (event) => { } function sendPlatformMessage(msg: any) { - chrome.runtime.sendMessage(msg); + if (isSafari) { + msg.bitwardenFrameId = (window as any).__bitwardenFrameId; + safari.extension.dispatchMessage('bitwarden', msg); + } else { + chrome.runtime.sendMessage(msg); + } } }); diff --git a/src/content/shortcuts.ts b/src/content/shortcuts.ts index fe9699ac52..f2d8de67dc 100644 --- a/src/content/shortcuts.ts +++ b/src/content/shortcuts.ts @@ -45,6 +45,11 @@ document.addEventListener('DOMContentLoaded', (event) => { shortcut: shortcut, }; - chrome.runtime.sendMessage(msg); + if (isSafari) { + msg.bitwardenFrameId = (window as any).__bitwardenFrameId; + safari.extension.dispatchMessage('bitwarden', msg); + } else { + chrome.runtime.sendMessage(msg); + } } }); diff --git a/src/content/sso.ts b/src/content/sso.ts index 508bc2aea3..a127a39d55 100644 --- a/src/content/sso.ts +++ b/src/content/sso.ts @@ -3,6 +3,15 @@ window.addEventListener('message', (event) => { return; if (event.data.command && (event.data.command === 'authResult')) { + if (typeof chrome === typeof undefined) { + safari.extension.dispatchMessage('bitwarden', { + command: event.data.command, + code: event.data.code, + state: event.data.state, + referrer: event.source.location.hostname, + }); + return; + } chrome.runtime.sendMessage({ command: event.data.command, code: event.data.code, diff --git a/src/notification/bar.js b/src/notification/bar.js index a5e55ca664..c882bd2931 100644 --- a/src/notification/bar.js +++ b/src/notification/bar.js @@ -3,21 +3,34 @@ require('./bar.scss'); document.addEventListener('DOMContentLoaded', () => { var i18n = {}; var lang = window.navigator.language; - - i18n.appName = chrome.i18n.getMessage('appName'); - i18n.close = chrome.i18n.getMessage('close'); - i18n.yes = chrome.i18n.getMessage('yes'); - i18n.never = chrome.i18n.getMessage('never'); - i18n.notificationAddSave = chrome.i18n.getMessage('notificationAddSave'); - i18n.notificationNeverSave = chrome.i18n.getMessage('notificationNeverSave'); - i18n.notificationAddDesc = chrome.i18n.getMessage('notificationAddDesc'); - i18n.notificationChangeSave = chrome.i18n.getMessage('notificationChangeSave'); - i18n.notificationChangeDesc = chrome.i18n.getMessage('notificationChangeDesc'); - lang = chrome.i18n.getUILanguage(); + if (typeof safari !== 'undefined') { + const responseCommand = 'notificationBarFrameDataResponse'; + sendPlatformMessage({ + command: 'bgGetDataForTab', + responseCommand: responseCommand + }); + safari.self.addEventListener('message', (msgEvent) => { + const msg = JSON.parse(msgEvent.message.msg); + if (msg.command === responseCommand && msg.data) { + i18n = msg.data.i18n; + load(); + } + }, false); + } else { + i18n.appName = chrome.i18n.getMessage('appName'); + i18n.close = chrome.i18n.getMessage('close'); + i18n.yes = chrome.i18n.getMessage('yes'); + i18n.never = chrome.i18n.getMessage('never'); + i18n.notificationAddSave = chrome.i18n.getMessage('notificationAddSave'); + i18n.notificationNeverSave = chrome.i18n.getMessage('notificationNeverSave'); + i18n.notificationAddDesc = chrome.i18n.getMessage('notificationAddDesc'); + i18n.notificationChangeSave = chrome.i18n.getMessage('notificationChangeSave'); + i18n.notificationChangeDesc = chrome.i18n.getMessage('notificationChangeDesc'); + lang = chrome.i18n.getUILanguage(); - // delay 50ms so that we get proper body dimensions - setTimeout(load, 50); - + // delay 50ms so that we get proper body dimensions + setTimeout(load, 50); + } function load() { var closeButton = document.getElementById('close-button'), @@ -118,6 +131,10 @@ document.addEventListener('DOMContentLoaded', () => { } function sendPlatformMessage(msg) { - chrome.runtime.sendMessage(msg); + if (typeof safari !== 'undefined') { + safari.extension.dispatchMessage('bitwarden', msg); + } else { + chrome.runtime.sendMessage(msg); + } } }); diff --git a/src/popup/accounts/two-factor.component.ts b/src/popup/accounts/two-factor.component.ts index 57aef6e4d9..a84ed5070a 100644 --- a/src/popup/accounts/two-factor.component.ts +++ b/src/popup/accounts/two-factor.component.ts @@ -58,12 +58,13 @@ export class TwoFactorComponent extends BaseTwoFactorComponent { // ref: https://bugzilla.mozilla.org/show_bug.cgi?id=1562620 this.initU2f = false; } + const isSafari = this.platformUtilsService.isSafari(); await super.ngOnInit(); if (this.selectedProviderType == null) { return; } - if (this.selectedProviderType === TwoFactorProviderType.Email && + if (!isSafari && this.selectedProviderType === TwoFactorProviderType.Email && this.popupUtilsService.inPopup(window)) { const confirmed = await this.platformUtilsService.showDialog(this.i18nService.t('popup2faCloseMessage'), null, this.i18nService.t('yes'), this.i18nService.t('no')); diff --git a/src/popup/app-routing.animations.ts b/src/popup/app-routing.animations.ts index 0c44460d62..b75b15e2a7 100644 --- a/src/popup/app-routing.animations.ts +++ b/src/popup/app-routing.animations.ts @@ -185,6 +185,8 @@ export const routerTransition = trigger('routerTransition', [ transition('tabs => premium', inSlideLeft), transition('premium => tabs', outSlideRight), - - transition('tabs => lock', inSlideDown), ]); + +if (!BrowserApi.isSafariApi) { + routerTransition.definitions.push(transition('tabs => lock', inSlideDown)); +} diff --git a/src/popup/components/pop-out.component.html b/src/popup/components/pop-out.component.html index 1c34a1c762..2f14e7c0cb 100644 --- a/src/popup/components/pop-out.component.html +++ b/src/popup/components/pop-out.component.html @@ -1,4 +1,4 @@ - + diff --git a/src/popup/components/pop-out.component.ts b/src/popup/components/pop-out.component.ts index df9ceba57f..40e02c6906 100644 --- a/src/popup/components/pop-out.component.ts +++ b/src/popup/components/pop-out.component.ts @@ -22,7 +22,8 @@ export class PopOutComponent implements OnInit { ngOnInit() { if (this.show) { - if (this.popupUtilsService.inSidebar(window) && this.platformUtilsService.isFirefox()) { + this.show = !this.platformUtilsService.isSafari(); + if (this.show && this.popupUtilsService.inSidebar(window) && this.platformUtilsService.isFirefox()) { this.show = false; } } diff --git a/src/popup/services/popup-utils.service.ts b/src/popup/services/popup-utils.service.ts index 297c6c1470..8f694c87b4 100644 --- a/src/popup/services/popup-utils.service.ts +++ b/src/popup/services/popup-utils.service.ts @@ -68,6 +68,8 @@ export class PopupUtilsService { chrome.tabs.create({ url: href, }); + } else if ((typeof safari !== 'undefined')) { + // Safari can't open popup in full page tab :( } } } diff --git a/src/popup/settings/options.component.html b/src/popup/settings/options.component.html index 938bcd756e..6104b85d84 100644 --- a/src/popup/settings/options.component.html +++ b/src/popup/settings/options.component.html @@ -96,7 +96,7 @@ -
+
diff --git a/src/popup/settings/options.component.ts b/src/popup/settings/options.component.ts index e5cf6322bb..4050060617 100644 --- a/src/popup/settings/options.component.ts +++ b/src/popup/settings/options.component.ts @@ -29,6 +29,7 @@ export class OptionsComponent implements OnInit { disableChangedPasswordNotification = false; dontShowCards = false; dontShowIdentities = false; + showDisableContextMenu = true; showClearClipboard = true; theme: string; themeOptions: any[]; @@ -67,6 +68,8 @@ export class OptionsComponent implements OnInit { } async ngOnInit() { + this.showDisableContextMenu = !this.platformUtilsService.isSafari(); + this.enableAutoFillOnPageLoad = await this.storageService.get( ConstantsService.enableAutoFillOnPageLoadKey); diff --git a/src/popup/vault/current-tab.component.html b/src/popup/vault/current-tab.component.html index 0d4181f5cb..4fe67ae9b2 100644 --- a/src/popup/vault/current-tab.component.html +++ b/src/popup/vault/current-tab.component.html @@ -1,5 +1,5 @@
-
+
- - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/safari/desktop/Info.plist b/src/safari/desktop/Info.plist index 201669d037..3c9b847a73 100644 --- a/src/safari/desktop/Info.plist +++ b/src/safari/desktop/Info.plist @@ -11,13 +11,13 @@ CFBundleIconFile CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) + com.bitwarden.desktop CFBundleInfoDictionaryVersion 6.0 CFBundleName - $(PRODUCT_NAME) + Bitwarden CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) + APPL CFBundleShortVersionString 1.0 CFBundleVersion diff --git a/src/safari/desktop/ViewController.swift b/src/safari/desktop/ViewController.swift index fbda5decf8..5af73ffee0 100644 --- a/src/safari/desktop/ViewController.swift +++ b/src/safari/desktop/ViewController.swift @@ -1,44 +1,14 @@ import Cocoa -import SafariServices.SFSafariApplication -import SafariServices.SFSafariExtensionManager - -let appName = "desktop" -let extensionBundleIdentifier = "com.bitwarden.desktop.Extension" class ViewController: NSViewController { - - @IBOutlet var appNameLabel: NSTextField! - override func viewDidLoad() { super.viewDidLoad() - self.appNameLabel.stringValue = appName - SFSafariExtensionManager.getStateOfSafariExtension(withIdentifier: extensionBundleIdentifier) { (state, error) in - guard let state = state, error == nil else { - // Insert code to inform the user that something went wrong. - return - } - - DispatchQueue.main.async { - if (state.isEnabled) { - self.appNameLabel.stringValue = "\(appName)'s extension is currently on." - } else { - self.appNameLabel.stringValue = "\(appName)'s extension is currently off. You can turn it on in Safari Extensions preferences." - } - } - } - } - - @IBAction func openSafariExtensionPreferences(_ sender: AnyObject?) { - SFSafariApplication.showPreferencesForExtension(withIdentifier: extensionBundleIdentifier) { error in - guard error == nil else { - // Insert code to inform the user that something went wrong. - return - } - - DispatchQueue.main.async { - NSApplication.shared.terminate(nil) - } - } + // Do any additional setup after loading the view. } + override var representedObject: Any? { + didSet { + // Update the view, if already loaded. + } + } } diff --git a/src/safari/safari/Base.lproj/SafariExtensionViewController.xib b/src/safari/safari/Base.lproj/SafariExtensionViewController.xib new file mode 100644 index 0000000000..3716df7567 --- /dev/null +++ b/src/safari/safari/Base.lproj/SafariExtensionViewController.xib @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/safari/safari/Info.plist b/src/safari/safari/Info.plist index 8f23fb23e0..82f785e9c6 100644 --- a/src/safari/safari/Info.plist +++ b/src/safari/safari/Info.plist @@ -9,13 +9,13 @@ CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) + com.bitwarden.desktop.safari CFBundleInfoDictionaryVersion 6.0 CFBundleName - $(PRODUCT_NAME) + Bitwarden CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) + XPC! CFBundleShortVersionString 0.0.1 CFBundleVersion @@ -25,17 +25,63 @@ NSExtension NSExtensionPointIdentifier - com.apple.Safari.web-extension + com.apple.Safari.extension NSExtensionPrincipalClass - $(PRODUCT_MODULE_NAME).SafariWebExtensionHandler + $(PRODUCT_MODULE_NAME).SafariExtensionHandler + SFSafariStyleSheet + + + Style Sheet + app/content/autofill.css + + + SFSafariContentScript + + + Script + app/content/autofill.js + + + Script + app/content/autofiller.js + + + Script + app/content/notificationBar.js + + + Script + app/content/shortcuts.js + + + Script + app/content/sso.js + + + SFSafariToolbarItem + + Action + Popover + Identifier + Button + Image + ToolbarItemIcon.pdf + Label + Bitwarden + + SFSafariWebsiteAccess + + Level + All + + SFSafariExtensionBundleIdentifiersToUninstall + + com.bitwarden.safari + NSHumanReadableCopyright Copyright © 2020 Bitwarden Inc. All rights reserved. NSHumanReadableDescription A secure and free password manager for all of your devices. - SFSafariAppExtensionBundleIdentifiersToReplace - - com.bitwarden.desktop.safari - diff --git a/src/safari/safari/SafariExtensionHandler.swift b/src/safari/safari/SafariExtensionHandler.swift new file mode 100644 index 0000000000..3d8711f401 --- /dev/null +++ b/src/safari/safari/SafariExtensionHandler.swift @@ -0,0 +1,101 @@ +import SafariServices + +class SafariExtensionHandler: SFSafariExtensionHandler { + override init() { + super.init() + SafariExtensionViewController.shared.initWebView() + } + + override func messageReceived(withName messageName: String, from page: SFSafariPage, userInfo: [String: Any]?) { + // This method will be called when a content script provided by your extension + // calls safari.extension.dispatchMessage("message"). + if messageName == "bitwarden" { + page.getPropertiesWithCompletionHandler { properties in + DispatchQueue.main.async { + makeSenderTabObject(page: page, props: properties, complete: { senderTab in + DispatchQueue.main.async { + self.sendMessage(msg: userInfo, sender: senderTab) + } + }) + } + } + } + } + + override func toolbarItemClicked(in _: SFSafariWindow) { + // This method will be called when your toolbar item is clicked. + } + + override func validateToolbarItem(in _: SFSafariWindow, validationHandler: @escaping ((Bool, String) -> Void)) { + // This is called when Safari's state changed in some way that would require the extension's + // toolbar item to be validated again. + validationHandler(true, "") + } + + override func popoverViewController() -> SFSafariExtensionViewController { + return SafariExtensionViewController.shared + } + + override func popoverWillShow(in _: SFSafariWindow) { + SafariExtensionViewController.shared.popoverOpenCount += 1 + DispatchQueue.main.async { + self.sendMessage(msg: ["command": "reloadPopup"], sender: nil) + } + } + + override func popoverDidClose(in _: SFSafariWindow) { + SafariExtensionViewController.shared.popoverOpenCount -= 1 + } + + func sendMessage(msg: [String: Any]?, sender: Tab? = nil) { + if SafariExtensionViewController.shared.webView == nil { + return + } + let newMsg = AppMessage() + newMsg.command = "app_message" + newMsg.senderTab = sender + do { + let jsonData = try JSONSerialization.data(withJSONObject: msg as Any, options: []) + newMsg.data = String(data: jsonData, encoding: .utf8) + } catch let error { + print("error converting to json: \(error)") + } + SafariExtensionViewController.shared.replyMessage(message: newMsg) + } +} + +func makeSenderTabObject(page: SFSafariPage, props: SFSafariPageProperties?, complete: @escaping (Tab) -> Void) { + let t = Tab() + t.title = props?.title + t.url = props?.url?.absoluteString + page.getContainingTab { tab in + tab.getContainingWindow(completionHandler: { win in + guard let window = win else { + t.active = false; + t.windowId = -100 + SFSafariApplication.getAllWindows(completionHandler: { allWins in + if (allWins.count == 0) { + return + } + allWins[0].getAllTabs { allWinTabs in + t.index = allWinTabs.firstIndex(of: tab) ?? -1 + t.id = "\(t.windowId)_\(t.index)" + complete(t) + } + }) + return + } + window.getActiveTab(completionHandler: { activeTab in + t.active = activeTab != nil && tab == activeTab + SFSafariApplication.getAllWindows(completionHandler: { allWins in + t.windowId = allWins.firstIndex(of: window) ?? -100 + window.getAllTabs { allWinTabs in + t.index = allWinTabs.firstIndex(of: tab) ?? -1 + t.id = "\(t.windowId)_\(t.index)" + complete(t) + } + }) + }) + }) + } +} diff --git a/src/safari/safari/SafariExtensionViewController.swift b/src/safari/safari/SafariExtensionViewController.swift new file mode 100644 index 0000000000..0241df00ce --- /dev/null +++ b/src/safari/safari/SafariExtensionViewController.swift @@ -0,0 +1,440 @@ +import SafariServices +import WebKit + +class SafariExtensionViewController: SFSafariExtensionViewController, WKScriptMessageHandler, WKNavigationDelegate { + var webView: WKWebView! + var initedWebView: Bool = false + var popoverOpenCount: Int = 0 + + static let shared: SafariExtensionViewController = { + let shared = SafariExtensionViewController() + shared.preferredContentSize = NSSize(width: 375, height: 600) + return shared + }() + + func initWebView() { + if initedWebView { + return + } + initedWebView = true + let parentHeight = SafariExtensionViewController.shared.preferredContentSize.height + let parentWidth = SafariExtensionViewController.shared.preferredContentSize.width + let webViewConfig = WKWebViewConfiguration() + webViewConfig.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs") + webViewConfig.preferences.setValue(true, forKey: "developerExtrasEnabled") + webViewConfig.userContentController.add(self, name: "bitwardenApp") + webView = WKWebView(frame: CGRect(x: 0, y: 0, width: parentWidth, height: parentHeight), + configuration: webViewConfig) + webView.navigationDelegate = self + webView.allowsLinkPreview = false + navigateWebView("app/popup/index.html") + webView.alphaValue = 0.0 + webView.uiDelegate = self + view.addSubview(webView) + } + + func navigateWebView(_ relativeUrl: String){ + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + let bundleUrl = Bundle.main.resourceURL!.absoluteURL + + if var urlComponents = URLComponents(string: bundleUrl.absoluteString + relativeUrl) { + if (urlComponents.queryItems?.first(where: { $0.name == "appVersion" })?.value == nil) { + urlComponents.queryItems = urlComponents.queryItems ?? [] + urlComponents.queryItems!.append(URLQueryItem(name: "appVersion", value: version)) + } + + webView.loadFileURL(urlComponents.url!, allowingReadAccessTo: bundleUrl) + } + } + + func webView(_ webView: WKWebView, didFinish _: WKNavigation!) { + if #available(OSXApplicationExtension 10.12, *) { + NSAnimationContext.runAnimationGroup({ _ in + NSAnimationContext.current.duration = 0.35 + webView.animator().alphaValue = 1.0 + }) + } else { + // Fallback on earlier versions + } + } + + override func viewDidLoad() { + super.viewDidLoad() + let backgroundColor = NSColor(red: (39 / 255.0), green: (42 / 255.0), blue: (46 / 255.0), alpha: 1.0) + view.setValue(backgroundColor, forKey: "backgroundColor") + initWebView() + } + + func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { + if message.name != "bitwardenApp" { + return + } + guard let messageBody = message.body as? String else { + return + } + guard let m: AppMessage = jsonDeserialize(json: messageBody) else { + return + } + let command = m.command + NSLog("Command: \(command)") + if command == "storage_get" { + if let data = m.data { + let obj = UserDefaults.standard.string(forKey: data) + m.responseData = obj + replyMessage(message: m) + } + } else if command == "storage_save" { + guard let data: StorageData = jsonDeserialize(json: m.data) else { + return + } + if let obj = data.obj { + UserDefaults.standard.set(obj, forKey: data.key) + } else { + UserDefaults.standard.removeObject(forKey: data.key) + } + replyMessage(message: m) + } else if command == "storage_remove" { + if let data = m.data { + UserDefaults.standard.removeObject(forKey: data) + replyMessage(message: m) + } + } else if command == "getLocaleStrings" { + let language = m.data ?? "en" + guard let bundleUrl = Bundle.main.resourceURL?.absoluteURL else { + return + } + let messagesUrl = bundleUrl.appendingPathComponent("app/_locales/\(language)/messages.json") + do { + let json = try String(contentsOf: messagesUrl, encoding: .utf8) + webView.evaluateJavaScript("window.bitwardenLocaleStrings = \(json);", completionHandler: {(result, error) in + guard let err = error else { + return; + } + NSLog("evaluateJavaScript error : %@", err.localizedDescription); + }) + } catch { + NSLog("ERROR on getLocaleStrings, \(error)") + } + replyMessage(message: m) + } else if command == "tabs_query" { + guard let options: TabQueryOptions = jsonDeserialize(json: m.data) else { + return + } + if options.currentWindow ?? false { + SFSafariApplication.getActiveWindow { win in + if win != nil { + processWindowsForTabs(wins: [win!], options: options, complete: { tabs in + m.responseData = jsonSerialize(obj: tabs) + self.replyMessage(message: m) + }) + } else { + SFSafariApplication.getAllWindows { wins in + processWindowsForTabs(wins: wins, options: options, complete: { tabs in + m.responseData = jsonSerialize(obj: tabs) + self.replyMessage(message: m) + }) + } + } + } + } else { + SFSafariApplication.getAllWindows { wins in + processWindowsForTabs(wins: wins, options: options, complete: { tabs in + m.responseData = jsonSerialize(obj: tabs) + self.replyMessage(message: m) + }) + } + } + } else if command == "tabs_message" { + guard let tabMsg: TabMessage = jsonDeserialize(json: m.data) else { + return + } + SFSafariApplication.getAllWindows { wins in + var theWin: SFSafariWindow? + var winIndex = 0 + for win in wins { + if tabMsg.tab.windowId == winIndex { + theWin = win + break + } + winIndex = winIndex + 1 + } + var theTab: SFSafariTab? + theWin?.getAllTabs { tabs in + var tabIndex = 0 + for tab in tabs { + if tabMsg.tab.index == tabIndex { + theTab = tab + break + } + tabIndex = tabIndex + 1 + } + theTab?.getActivePage { activePage in + activePage?.dispatchMessageToScript(withName: "bitwarden", userInfo: ["msg": tabMsg.obj]) + } + } + } + } else if command == "hidePopover" { + dismissPopover() + replyMessage(message: m) + } else if command == "showPopover" { + if popoverOpenCount <= 0 { + SFSafariApplication.getActiveWindow { win in + win?.getToolbarItem(completionHandler: { item in + item?.showPopover() + }) + } + } + } else if command == "isPopoverOpen" { + m.responseData = popoverOpenCount > 0 ? "true" : "false" + replyMessage(message: m) + } else if command == "createNewTab" { + if let data = m.data, let url = URL(string: data) { + if !data.starts(with: "https://") && !data.starts(with: "http://") { + SFSafariApplication.getActiveWindow { win in + win?.getToolbarItem(completionHandler: { item in + item?.showPopover() + self.navigateWebView("app/" + url.absoluteString) + }) + } + } + SFSafariApplication.getActiveWindow { win in + win?.openTab(with: url, makeActiveIfPossible: true, completionHandler: { _ in + // Tab opened + }) + } + } + } else if command == "reloadExtension" { + webView?.reload() + replyMessage(message: m) + } else if command == "copyToClipboard" { + let pasteboard = NSPasteboard.general + pasteboard.declareTypes([NSPasteboard.PasteboardType.string], owner: nil) + pasteboard.setString(m.data ?? "", forType: NSPasteboard.PasteboardType.string) + replyMessage(message: m) + } else if command == "readFromClipboard" { + let pasteboard = NSPasteboard.general + m.responseData = pasteboard.pasteboardItems?.first?.string(forType: .string) + replyMessage(message: m) + } else if command == "downloadFile" { + guard let jsonData = m.data else { + return + } + guard let dlMsg: DownloadFileMessage = jsonDeserialize(json: jsonData) else { + return + } + var blobData: Data? + if dlMsg.blobOptions?.type == "text/plain" { + blobData = dlMsg.blobData?.data(using: .utf8) + } else if let blob = dlMsg.blobData { + blobData = Data(base64Encoded: blob) + } + guard let data = blobData else { + return + } + let panel = NSSavePanel() + panel.canCreateDirectories = true + panel.nameFieldStringValue = dlMsg.fileName + panel.begin { response in + if response == NSApplication.ModalResponse.OK { + if let url = panel.url { + do { + let fileManager = FileManager.default + if !fileManager.fileExists(atPath: url.absoluteString) { + fileManager.createFile(atPath: url.absoluteString, contents: Data(), + attributes: nil) + } + try data.write(to: url) + } catch { + print(error) + NSLog("ERROR in downloadFile, \(error)") + } + } + } + } + } + } + + func replyMessage(message: AppMessage) { + if webView == nil { + return + } + let json = (jsonSerialize(obj: message) ?? "null") + webView.evaluateJavaScript("window.bitwardenSafariAppMessageReceiver(\(json));", completionHandler: {(result, error) in + guard let err = error else { + return; + } + NSLog("evaluateJavaScript error : %@", err.localizedDescription); + }) + } +} + +extension SafariExtensionViewController: WKUIDelegate { + @available(OSXApplicationExtension 10.12, *) + func webView(_: WKWebView, runOpenPanelWith _: WKOpenPanelParameters, initiatedByFrame _: WKFrameInfo, + completionHandler: @escaping ([URL]?) -> Void) { + let openPanel = NSOpenPanel() + openPanel.canChooseFiles = true + openPanel.begin { result in + if result == NSApplication.ModalResponse.OK && openPanel.url != nil { + completionHandler([openPanel.url!]) + } else { + completionHandler(nil) + } + } + } +} + +func processWindowsForTabs(wins: [SFSafariWindow], options: TabQueryOptions?, complete: @escaping ([Tab]) -> Void) { + if wins.count == 0 { + complete([]) + return + } + var newTabs: [Tab] = [] + let winGroup = DispatchGroup() + for win in wins { + winGroup.enter() + win.getActiveTab { activeTab in + win.getAllTabs { allTabs in + let tabGroup = DispatchGroup() + for tab in allTabs { + tabGroup.enter() + if options?.active ?? false { + if activeTab != nil && activeTab == tab { + let windowIndex = wins.firstIndex(of: win) ?? -100 + let tabIndex = allTabs.firstIndex(of: tab) ?? -1 + makeTabObject(tab: tab, activeTab: activeTab, windowIndex: windowIndex, + tabIndex: tabIndex, complete: { t in + newTabs.append(t) + tabGroup.leave() + }) + } else { + tabGroup.leave() + } + } else { + let windowIndex = wins.firstIndex(of: win) ?? -100 + let tabIndex = allTabs.firstIndex(of: tab) ?? -1 + makeTabObject(tab: tab, activeTab: activeTab, windowIndex: windowIndex, + tabIndex: tabIndex, complete: { t in + newTabs.append(t) + tabGroup.leave() + }) + } + } + tabGroup.notify(queue: .main) { + winGroup.leave() + } + } + } + } + winGroup.notify(queue: .main) { + complete(newTabs) + } +} + +func makeTabObject(tab: SFSafariTab, activeTab: SFSafariTab?, windowIndex: Int, tabIndex: Int, + complete: @escaping (Tab) -> Void) { + let t = Tab() + t.active = activeTab != nil && tab == activeTab + t.windowId = windowIndex + t.index = tabIndex + t.id = "\(windowIndex)_\(tabIndex)" + tab.getActivePage { page in + guard let activePage = page else { + complete(t) + return + } + activePage.getPropertiesWithCompletionHandler({ props in + t.title = props?.title + t.url = props?.url?.absoluteString + complete(t) + }) + } +} + +func jsonSerialize(obj: T?) -> String? { + let encoder = JSONEncoder() + do { + let data = try encoder.encode(obj) + return String(data: data, encoding: .utf8) ?? "null" + } catch _ { + return "null" + } +} + +func jsonDeserialize(json: String?) -> T? { + if json == nil { + return nil + } + let decoder = JSONDecoder() + do { + let obj = try decoder.decode(T.self, from: json!.data(using: .utf8)!) + return obj + } catch _ { + return nil + } +} + +class AppMessage: Decodable, Encodable { + init() { + id = "" + command = "" + data = nil + responseData = nil + responseError = nil + } + + var id: String + var command: String + var data: String? + var responseData: String? + var responseError: Bool? + var senderTab: Tab? +} + +class StorageData: Decodable, Encodable { + var key: String + var obj: String? +} + +class TabQueryOptions: Decodable, Encodable { + var currentWindow: Bool? + var active: Bool? +} + +class Tab: Decodable, Encodable { + init() { + id = "" + index = -1 + windowId = -100 + title = "" + active = false + url = "" + } + + var id: String + var index: Int + var windowId: Int + var title: String? + var active: Bool + var url: String? +} + +class TabMessage: Decodable, Encodable { + var tab: Tab + var obj: String + var options: TabMessageOptions? +} + +class TabMessageOptions: Decodable, Encodable { + var frameId: Int? +} + +class DownloadFileMessage: Decodable, Encodable { + var fileName: String + var blobData: String? + var blobOptions: DownloadFileMessageBlobOptions? +} + +class DownloadFileMessageBlobOptions: Decodable, Encodable { + var type: String? +} diff --git a/src/safari/safari/SafariWebExtensionHandler.swift b/src/safari/safari/SafariWebExtensionHandler.swift deleted file mode 100644 index 001d388e6e..0000000000 --- a/src/safari/safari/SafariWebExtensionHandler.swift +++ /dev/null @@ -1,109 +0,0 @@ -import SafariServices -import os.log - -let SFExtensionMessageKey = "message" - -class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { - - func beginRequest(with context: NSExtensionContext) { - let item = context.inputItems[0] as! NSExtensionItem - let message = item.userInfo?[SFExtensionMessageKey] as AnyObject? - os_log(.default, "Received message from browser.runtime.sendNativeMessage: %@", message as! CVarArg) - - let response = NSExtensionItem() - - guard let command = message?["command"] as? String else { - return - } - - switch (command) { - case "readFromClipboard": - let pasteboard = NSPasteboard.general - response.userInfo = [ SFExtensionMessageKey: pasteboard.pasteboardItems?.first?.string(forType: .string) as Any ] - break - case "showPopover": - SFSafariApplication.getActiveWindow { win in - win?.getToolbarItem(completionHandler: { item in - item?.showPopover() - }) - } - break - case "downloadFile": - guard let jsonData = message?["data"] as? String else { - return - } - guard let dlMsg: DownloadFileMessage = jsonDeserialize(json: jsonData) else { - return - } - var blobData: Data? - if dlMsg.blobOptions?.type == "text/plain" { - blobData = dlMsg.blobData?.data(using: .utf8) - } else if let blob = dlMsg.blobData { - blobData = Data(base64Encoded: blob) - } - guard let data = blobData else { - return - } - let panel = NSSavePanel() - panel.canCreateDirectories = true - panel.nameFieldStringValue = dlMsg.fileName - panel.begin { response in - if response == NSApplication.ModalResponse.OK { - if let url = panel.url { - do { - let fileManager = FileManager.default - if !fileManager.fileExists(atPath: url.absoluteString) { - fileManager.createFile(atPath: url.absoluteString, contents: Data(), - attributes: nil) - } - try data.write(to: url) - } catch { - print(error) - NSLog("ERROR in downloadFile, \(error)") - } - } - } - } - break - - default: - return - } - - context.completeRequest(returningItems: [response], completionHandler: nil) - } - -} - -func jsonSerialize(obj: T?) -> String? { - let encoder = JSONEncoder() - do { - let data = try encoder.encode(obj) - return String(data: data, encoding: .utf8) ?? "null" - } catch _ { - return "null" - } -} - -func jsonDeserialize(json: String?) -> T? { - if json == nil { - return nil - } - let decoder = JSONDecoder() - do { - let obj = try decoder.decode(T.self, from: json!.data(using: .utf8)!) - return obj - } catch _ { - return nil - } -} - -class DownloadFileMessage: Decodable, Encodable { - var fileName: String - var blobData: String? - var blobOptions: DownloadFileMessageBlobOptions? -} - -class DownloadFileMessageBlobOptions: Decodable, Encodable { - var type: String? -} diff --git a/src/safari/safari/ToolbarItemIcon.pdf b/src/safari/safari/ToolbarItemIcon.pdf new file mode 100644 index 0000000000000000000000000000000000000000..dbf98ce90e57cfe02aca29db06dc6f2678a6586d GIT binary patch literal 46066 zcmb5U1zeO%`#vrr-Jo=6M3IftdH-d_EN&TN^ zSI;@`_q^x*pWpb|nP+D1xUPHdnP+y9QC&uk1H#FR!??WiX%q(&Ld#9-Y;Gqi%B2eP zFtarCFr$SC0%Mw7>Soq3cUma?S=-kI#w86*IyhU4iQ&MUECC%Ha1Nm>?d<8~K?~u6 z%fS`7lx;2D4fsH9qdzsk{x1zTXBR1F9|I^KFDD-@l#d6jxq0~!4Id|;5dgpq23SQP zkal+R0DA7U5FR)pDF+zL5;VZg$xRD>pgi1vHh{7%%mSw9WaSJnQ*?0fboX#G^Kf>f zgcY)r7e_d%9V`+yOM|0CSj| zhb_FLD$NZHNjp0@yBR>ap-^6c2?q~UfL{=NfN*jfap~H++nPJTXg%CKVO+A7wjO4G z^ki%u9buNVR%Q$9i=xEz|IKY@y%zR;Pv=A0Cpm53hc*tvc zn0dee@A6vQv=Dfd;ZuLCA(x7oyN9Z?rLC1M%#v9~7|IP1;ueHLfsYVWh=ZH=4mbB5 zz})Y207HvdT)?{*V4D^ZoBu~MpwjQGa`G^cY=o&0`3$| zE;Ua_DFK^X;GAc_C4AOzU1pghn3!oM0|5LA$Hw(xWWwk(kEK;pZ@bKG5+R@&FhiB=Uj zTY$Bt)q^=WID6AdIha}413Hq5d?s?X4lsb5qnQUSum|9S5U7cir>%n}5PNeNz#1YX z2p{J$fg9Cv2dn{x!A%4o7cfzD1ctO=9v%QGz$ZMMn(|W8z%k(L2p0qf;s2NoG-1}Z z@Y4Z^kr5a%Nkzagut^=w-0T5m4hS!B9DxmZI6?S-H^AU8VQrYBivti{c=!bPxB)Q% zULmml^ZBreb0mKH#9q!+Az)HZ66Tk_=ppeNu7nqZ_vo3G~0U_go0>?BDgcsOl zoKWy!hYHdP2|;L~{BRdS08>QxAwVB=`WqH#1+?+ox&Q!%5qzQCbF;O!brR;_g#tkn zKrj<9k$1DT{BxNFpul#AS8yJjz`p$B10sQce#HbJ?*Y6}e&YvHOj`9%OTeo{RSR@2 z>treI3>>n+n+FFI2NQVl{o2;QUV-2-@*i8Dj~DXSz5kzE3uMg!Y&{4M5DN$&1YGI= zvatX$c)|&S`Nkszb^u|4U->3r0)O*3nc0HN$t?(+34*k6OHx24gWKFpn3fO952aOr z`Or%FdcXkajNCxs0dj?#o1Y)7g@nLbTKe}W4^Y5oaF&M$oaNy`%*x5Z)e&m^{Gb>= zP{0}#Krta9Pz-zmYfwyD8WfY322Du=1*}0C zIXQ4vPL3a3YVZLRkds3wAwVM_BM>2cB4$BVP#LUwcmQ><0mVRGU=51#@PJ~V@85GE zDexJT0ZD;1ocr%^Ae2E~;0V|QNr5$J3WWaKG)M}3hI0oWKrxUP=oJ)0Km^%=&!8y; zH94Swn81L7asYVn0TFLRL=gc)sDd*f6|hFYN1#Lahl59`A|}BaQ9v<}BUpo`5DST5 zj+pzkaNrau26Ftp(umL?ltGT*IM@RTBJu#Ne}M;wz%D2Tas+F{Vj|K75nKcU(C6=5 zfP)7Cfnp%X-{8O}@EP=s2p$+A&^$N;IsZ#fny*?utwkmWe}nL9XvRA5D0=jCYy6P599^bpa3FR2y@^lm>Hl9Na}Z%{8~7$i|`7H zfxN&P6az_tH6r2&OrXr4;e>ntGbF#^f}&vg?HPPVES0o0aNfcT*aGU{GvrqRZoBaE zXA6ZFUS7obpDi!E0P1{vh^aqYK6nujK#c#{3c!mX@LB~6*#7b^_^U`tBBuUqCE-O% z3Nij?D+Morx{M5B>d#gNUSwqv|a0BLk@a8;<`(5K#X& z9RGd{~~Svi6Efo=5d7cL=I#s?59AO2fY@&m@Q8qt@YDv8!VN(X zh5&WY7F-y-VaV`6|1biG{KA5G$Ns)=z-8b@z#G&rK!9%$*sm*z3<3*62s8_43|va! zGT@gIxDh}d6agH;!9f5cV4oYf%K$B44hndd6NJmi{&EE3li`M69{_a_I1eHiG7vxn z?Ehf~_-+995k}x)Lo6tW6EF;d{|!kJ4(Ycc;P#5}51Il^a7+Cjfy(@j51*E`_)@$v}7k z^NU$2h`z^fY&0tMI;PpM(8&Y$RD>$$RFtq zL8Lbb9xC+<(yz-F z=x=<#_8P(*pbi%VZps4i00K6Dz26}~tl?`8_yuLa%QRf}S094-!UY!rR1mqv{X21f z3juuq4{)c3n*dbcH~~sv9|RAY1@3jfxpBi+_)o}STESiX+K15JD-SF(96UG){E!Q` z2cEld;XhLd&K5KdNd3YD1)>B>f!+Z@I51!c4h(Utmx2CO?T^Ibh9B#ID%=>T4%q$W z8GsEB4zL-37GB`UfTaZuLlOHx1_Cz*j3B@Rpa74*mk$aA3z6>-AOrcJzdQjFpedfe zE~eBk?|=b#mVv%`5a|PW0}n~iD-R+cz$aj@!$$zmU<;^&dmU~UfW`+ust}3un-b4& zG=MlFSrAFa3#=HR4hITO{aRaIcyNF|;2Si{`?nAxBE<_t>M!abQxH5a;0+!?AfSjf z2VwF7oIzs{zzL9JfE*v-=CA4iZ~+;(D157e!r*}fwtVoI15bbtN|Kmqh2%i8iFBkBa0S}m) z0T=kwEldVx0sNp1{Auy`pIH=QX77J55!J+)~o_Y7XS zOfllQ+qsO>R?<(dD+;+@{iM*uV{0%kmEE>;xc&`tKHx7%*>-a5<0gpN{fyF!lH_ji z(Q z&8iQe4~>Np5(~QuiZDMz+P5z^^Bg+5XxR3X<`q18Xyai^d*}8N-P%6Uu1g$lTc|T9 z)#~ABtTT7;kbORXX!dBWPZOD;A9B8#4@VfoZNtPu^k~iEFpScK;Z00;kC-o2VZ$a7 z5Z6RCQrsUQ)*kb(1C_~;7H*dhclZelAD_=Wji}i%B2>lj*}_3fnQyL_T3~yO8a7Z{ zNHdo$FOiO4(WyIRi|iHX<%VfS`~>Ct@MWWz$y1^F;fJJwLLX3+3FIHlv#eM(TF-qy zst7)xq#*3Z&bKdc8*?|iC=ubEJaorJ*=8@FK@Otv{CTwAoRxs*1ChMO&ED4**w13eKUkg;GDAH)TNoR)tx;~^4duH)- zuyf!5zQBXQn5Y+6DK-WH;Zj(Z%hz8;_ZB~AX?}$9hJdC#?;+K~x7>tDs@s#h{vW(A zkjQcGt{zuBqVq{FJ7i}R_^1GZx^2ocxmzf#C%FEc4Mm~XYEvF#3gH;o(5+m z_V6v)v!J)<&(p95?Nxo8#GifMgq4~aesMF`ezUYx8m&V7Y>ru-jS@3G#yf66n8&QCdQ%9EAYOeX*U%E2CbJ*YjvkFdmeC^ir`bTex6|A2$W^P zQs>%$-VsjiLF2pa%z!H))RG>=7>arsR~3}bp#797zlX^x$z*p-C9MRdg&^!!kyVI8 zX%*=sj^hY}6Uwn764P+;AqUf7&6}rnItBC^g0ZK4=3H znyo>7J(lC#b5-VM#y*ZJu^_)?cz}TvfAtk#TIZ5ZUqxa2l5&@f%EUofnBK2y-7kxw|?-rJJ%_btv|T^TK$2A$SA3F&QX9 zA8`*K*57qctxOBQ_jU+*tLfqn$?OVu9Y)hC}sCdU+V>0cKh`$YR_TD zpXc4OOYRSlt99kki(Z8K9-fogg%_@OwxHxO3+d2gL9ATVo{Yus9*BIO&-8Ra7YpRc zo1qNrK{{8PSih&8fTQm2DyXMC=+VeHm>jcUbny^kNxcM_<2-koZXgvSlSX< zewobs7`e#@l4tn~4&R6U=KE4x=jn@-ud7xjbaSMw=j#c7l^}VVvh9N59`e}ckr^Hu zgSFHa(MFbmC{h6gGh#{>PeCg4#(h5aYKBJ&pO4HHEgj6W-0NBk=mI)Fi+?U8;ux?T zNe>XLB@$5Sp|LF?i60Ke7dF|b7fKtIjwsB=WS6d@v|7MktlXl?C-rNUc_fGNw(zqf ztT{swMY5Ta6r!b1s%6bX%u4L1MH5xosu)|<^6^`KjIRYZa0w4_P_26JNa3%N6L4j_#rR{w~KX^7ySTd-V&SzW&A+(-g`_Zy#TIiyBvG zbcWTaC*3)Az`%rYZSoGw+&X*ta^v}X%nV{{zbmc}hu$X*W=&@HrjFmdE1kk+Yzf-@ zt{d^sU7^_a8kH&~8VMWo9}ux>Z#6#eNq&yQcKF^tJZ3Q=0w=5o<<1%TtJ#=7qgY0V zi38>`Pd7{xw`)5`cpX5l^2Zp@e;&fe~3}= zX?j2C_@Mn|EhBM?)Qd|)7q0f)O?Xi_Fq-D zxjD}yvO1GY2{=_GwjFw}8aJnjsISf5#W~Cmb-wJFQ(l0c9e-<$lHSU3GrXF8`t?im zt^qT6a69v-4mW?v1J?Zmqoly9WPt$PxQ`OqxNqW*W4|n8;)~%^e6!Nx>mTt^!Z2b) z8De7^iG`oP*vbFmebG5;`i7yASU<(U{+)-BPJLMM%q<%iee+6lY!(dN+{`G2VP~PC zz?<`;N71aERZO%=pQnNZekQyvj!@uLAW<|7^&G~2XWJDc6Jqg<=mlLkPM(so>;0RG zA3Y7v9_~FHNy!Wle0?F&dlBPva&UjpWy8~7jcaZ9XZJqpOzBP6w5{aL*wDvx7;H*p z^$QpmK64ylxShUPXlP=0lSqwB#qRB7=2l0?;`_GcAM0letY8Q~6?_qew~uc`ST8Yo zBk2igH<`hEj@nZjj|5JYR5|EcjekKDo6bbYR&~6t9x@RP$s-NV z^yI>~uMg+b1UKl@^0ko8IAwwxnP^`11k>ae;=z7qk6t6UGOnOVw_ve)=7@Vhk&Axf z4tvemr_PGz&Z#uxuAGlDKuEm`6*ozG%%o9zv;Dj7KE6odO1%;qtqdiTC%O(BJ-&!J za&*XWfHL*rOFFH7ZagpBN6AF!l_(3z=+{(BOWy@(=~|HL5jIFq#@Wj=YSlMC<%iI* z70w0LFywuF>QXjHvG|eEkc=oCH}}DP>tDQp+|nJ5drm>U&Nu7_3-Ow@68E*Xw(KC638Vl_1K9ro#%btAyOcnTvQleIB7 zx&3|V7rr25C4x32b{wvZ&nUPt419!XB+<-0BL2yn3r~6*U)pl^Emgh-Jxv?*yK`;!a% zq0H&G?;by=9^KEnX#H}RXVlrev2*&MYX`FAIF1%VHAkhTdpvNLa4fR<##}F|q$7?( z&5Qoo_$)Q6eYCc{{6SMpudE z3Y1ur!l%t;(jvP0UDuq5Is+g%qD@b!e7OkvEt5{^mnoPrX}^c&w>=}jfrS1*nLiw- z&aAJ@cY^oLz@t^&M*?9;9^u^P)-?~$Zumx$D-PXFOgLQ?eXw9sw}1Gg42#=)e5O}7B?uLr!Wni2)S7-_ZXt{ z{zC-?J|%;W&XIyQ2T=vRgDDYYC6^Ke$pkSCPJHkiK3_fRoLD3Ydv8dfrR~s0$gS9kuPj|= z>#g>fy~1sR@Cch9Uq$&XuP(uA2vzH=CG~cH-u-z4BP3P528Y`PUp=W)3ZTUg;kwo+;LLA zwDc&v3s>AGJaCVfl1ZJ#U&?e|*7(7%XP-|cuQG0eM-B2;UqA6FtezrI6O3@1#auh!c(zY5sFRM*Bda$}-nTtoxV@zFo?=2v zHh`vVAx5q7SZeusElH7q|B{4I+o$pIBJRlGi<7jBXQ92UqGkKWF4H9U2)?05HR` z_2~=ei+qEv$fB-J;c24fCr;CwZQ-0y_0no?D#fntYu&!wH!?yxBi^c`Uccd==|!+4 z!h8x-ZBdH4Jw|BZX=AR@q5FZUx5;fy5O?kz8T zi$)Tq`S5KzSh#OitZADuPNC1nygh7A9iZ;0zx!P2i%#VH%YcAlCrN>MyE~Fs7{WDz=LJ+#OM_S&kBXpY zO!4z{SquK(^%~ymeePsiIr(9*KvL43s}rVnWlV8IR+m#G*efWf-S^-oyWr|meG^|! z<+rKS}a$AVhvS)!H|Dy|3ZI{znl-P>fE`9f+ETm`j0c~uTVn+=(> z_&4zUsPE-W^f~v&>N8f7@vZP@T<58GJCpG))Mpa9a$Ts@aq9|6sw|_-QjuFXai~AN z!yP@^uZI!rlzQ-WCa;U6-VarYEQ-B<-O}qwerI7DUC9ZCNB#? z@Sp?ClCY>*<*Zh>IVO%ucCk*fZD2wBN_oHh+y2Q1{-ZST8-Rn>f^VFcKmoiUV`Hb?Dq5ErX!$#`h3oZ6a<>IGF z2NTPRr{9^9hAhN6^6#Pt=U*GNUrX`9u)f>o#m7DwyuBh&Hzxotax1`F|{%uPdiBG=GoyYnM4OQnIj1?kB{P1IcLINBNO z%5>V@EQcJrWV>92-+2%0zE!YV<7?Kt5JmvcF_m;MQ#`3fr z82dBdWqVo{g#`K3)_=Zj%5?XlIIZ?7MklTBs}{`gjpu5qPvEl6M=@B&P-o)igL?f$ z>&=YQL#5%97R`0>AM@YsD(|8O8bP`;)9BG0f6E2VZ{ck||kKw+C5^J~{UVP+y_ud^E_PX>WpUxL;26 zCS=%K-%!U&)!!5I;xQM`di*kytwEPM{R;nWiftf{)1BsQm8Nc=v3rI($f>!Uu&}hB zJi+{*)K~>P(ogkX*PZ1;OSyRuKFEs7z3gtB^K-m2X<*n$s&+OT9R>S>A-kfgQ#MCC zZ$S3inH5qYYL;I+!|Pq*T3mIrB==9_J05@KD*-knf`W&}kF`Di*; zk&XF|mGGrj!brogvb5Z*ofcs~^6o8un%Z@u@)KVjPT314Ka>g3pS_>5EY(!lferb- z1G$!A_k?NYUTomybUt-+50O~-QmOUmlw9S0ePBPe`~1_7*jd4YJul-GulGAhg*P>k zX%+7a>M3~0^B*zrh|9Uoux6#(kgPuJSJ z+-YXanS1)Zal$$wRcGKZM-?V zrA9g@Qep$uqQlf7{CyUopCWUpLZ`Nj0l&+||iBWzweICS+#hWPR3pTh?y%_O;7fSZ9n; zNryTqKZbB)d2^JuE-Tc%3yL#isOU6bRC5yd|CA@Pi-xSw)s}FjLI$HTS@Udrgy9)8R*oD(M%0o$D(1kKA6{7 zp><;0Mi``UaCp*Gg<}oOT7EMN3eJklQ2A^n%r|gA(rgz*;nk?erhT6Pvi3c=n*ZF? z;b$n3c?pF-5-9 zM;odO%eODVrBv@$;y_Qm{s#9rMablDDQ3 z8v5uEtN7a8cL8j8q+`PwF-DfSi?rCzv=7BkHXYbBP*ptAy!sO@SWF0x&+Z`D2MOAqPa(J4PvOw9Z=D$>+5rsGNK5|}qd{0!&zV;+o5Sr*lznwIR)`))li zBA&DN3p*dPcUjbye9I$oEBAu93Zfi(N+gLLPqn{%@vY_J#myOxQd*<|%RY#&lasyU zWgqq?@wC{RqKLkXdviu#rZTi#o6oO#6bpR#fHJ?mVilsPtlKrCJzsVOU5zVQ8Vd`lxR3Up zW?xS7l5kP0Q&2453(_&}k*Rsy-yu#eBtE*eyKchcZL^M9;(;%wMn!w0+GfirHNyMZ z-fM#Q`t~!$uDAD__2c?QKi6$K?Yfjm4Xhut&2Jxz^a}^SUcT^qBTY$O>E62=j+0*& zr^v~C@A!;csABIsqse66$eEkQi$HwFP>7tk;u`M)*@f;Z{>WvMJAT6*8vzC3Z?k<0 z<6-{ae9gu?zt!EgkNv{3`i>8G4b@fEEoIZe$w<~7J44*TYUG@|c!`N^u<%v(%@E`E zxF3h}6%;Aay9bdO1K;YHr=G}^|FdSMf?Oe(4_iDPfMykf;29`=EY0ODISC-A=+2x2#F#XJObX>Xp~Yr@u7zv7BcU>T~wY`n=7yNj%Oe zDJ>Cb)b_qKduN`b(3HD67E_FJwzfXqq1=lx~OT8t6#lRdWp1>p4=0u)2KED@;dn)H$5<@$(V}s|5zHiI{X1k~P8FfakfS6P^@uCESG935#utPmH@*`eB7e2bqrF>eOs5mOwr9Tm40?+f6etF z^}x}fuTxCaGewS$QKO${$j0Q>9|UK1`d$j?_bj}FO_R8MPQ3L#=jCm*W&D>?TAY-2 zW?n^!ICP^iUidfCYY1+Xh7!2ole%{dWtw~@>c@aShM~o1C%cZ41S|&KgeDV?jRf0% zn9ksS8ryB9!T=rRC=z|n8L}xm`pvdPy78E5oseO>f>_P%#ib&8+TL*&WLv#`xmF+a~=8^2&5z zWx}I}T0O%*j*uj12O>_^q!^4)lH)XQK5+e(8&Ays!bQRqt>UH7i1oZ8hBFVB!XS_$@P8t@#!3P{F9G2XZ-R4ID=hj zTDnJFDOj!iTjVjyGrXMWT&60pXUH;iTb{a3W@r0Y4gI+DKut?pfvPtFwcVKTC0o_T z-J6QR)vq+m>xCmuq$Z0T?^+=}V^V;4BIDlKqPQut{IQJ;oy;2#b|q`5Q`7s#zpVSJ zvOaE6U#hUzGO1v=RiiAeTgX6|t}gbK`)9Q{F&X;+#z5fRswMB$77C)2W6S4s?8Y*chZ&|HiOS}rZO_S5;Y<_g2jD9Y zFg_~~*cSdq1<4&5(NSO6k$&7?Yf+VbqB|*)PjefjI2l8IPvuB!i_=|Zei{Y7Ruo^+^O{1gr7^X+t<|{0Y?A$pjyvM z^=ptl$A{Bg$hAMGbeOUXMWo}EG7ZmZR$vV;a+4yK&SFx%T}&h;Z@Q9Lj(U;Q*UzDN zqe#*Zj*FgY(``)%LcB3$1C#aAf~^u5!|+Nt&V5}UiG@jg^)J7))O=$0ej0`2p`P;) z(d{|3*M{f}k)btfdyQynCRYK9(cH@A?CW}D2mBku6leH3f=A@{a3$G5;m8*qqE zNDSH&7zuGk7kuXx=y2L()PeiyYWADHK~f{hfm!8jDQR@w*GO4%>nX~#j@G(2^A{~V zXff7ti3TknARh>d2i>&|0<dNQf#Z`(VU6OmKIZp<19rAf4FW;6Z zqnAAU>g!Ak4dk5Nd6*{c7Iyi{^OW2~KhXDDdgAB2C7B)NZIW6vF&mPjV@C1ZWIGgz z1nk{61T2Q8CDi57t9^$g{T#&s_f;WZ_{)>ft+%%t3b)>0D}+8o_crxLclv+96%_QBw94t(M@``_HBeh3&D;I!&P_)L!rQ>RxH~ zKPvp7>=$-OFHrIs8zllCrusIAsl+5L4mr!f*5mBv;hLO-exMKM_(72Orfvh8f8I%E zg8JFvjCB=$-Ms?lG-KWXUPe+9)Iu-np|DT)^p;$%AJ;!$yF(GJPOD&Su;skC&ndP3 z+BKnPFL_04=v=AXEZ#xYgw)hc)G z6G^xmM#7>d?>ODlrRX^YB{3RrXWhwo`+}FKK-9lbsAMR2x?ZAmH{4%nnD6sPw6s8` zSbg*3hP4)-kV1_HD6`l=>-QNqNDM8_+A1&BdPWbEeS0LUDaYg4fZnrAS z6(resiI<9TBi8$=pE7$DTW}|usH;`>r!h>!6A9n!NVRYLYb9g?tt5EmW~6iu-OXK> zm6I+t-p0d?N!t}nqdEL<_S% zJ?RT-I@6RJPQIS1B~c0rEZ0jdPgq8j-(0IOGM(pTP(xXc#+~olY9B7Io8Jji-`4JP z!fVNUa=juuzC@L)14&YHqH$^zy^w9+cxx%_&M8V8#YVe*tI5vZZ5zQUp^atcAq$1T zdl3~8b1~KjOUp|sSTa`27Y{o9ygW{?q0V1EGctSo{pq{aUe}t`wA}lGCMuG8PXcqT z3f%PzZ_SJ5`nJr-$%G7c*v2Sk_Yn>zOs;&c2(4>K@9YeB>Z2uS)HB^zmR$D9Q6K+t z`)i_LU@5htZiBt16xv(TFEVKx>kOanmU^0?J-mh7FKM zzAnk5g5JG^qDs;}#u_b>uZ~7T1Y3p&Tegn1vVlR`6z<#s8VNdI&hp7Kjft73FV5Xh15dYuWtU>V$S2g%D|sueL6c28=WCXmY-Pgc zkl$MSsvLPfT3-lII`~e_g&uXAnO5(;8FALGUClkyy;19BZI(^Hpddorwz;shaedzS z%pKRFkYnZv4wJm)YDQPy9!Ue&>w`EA%cx7Xq11i^-T6!U?AeZmJTj6k+_|v?NBOU_ zGQw@hSGS!lmJBmy1uxP)yl!V?ZD4cJ8{dH@2WKk8Z3`mjBUOanLnX*hzy33Mw3XP( zoo-K`yDU`te!@iF7ssBTd}TLP>~Cw(yQ01kp3Y(HkP2VEr^)`9+{e^0Ik3rAVa1g{ z(6XE*q^NSKb@8A(K2_sv?m1y}P*2haR}a_6g5j?CM@XkQeAF<~MP?|SL+1iPj?4Er&x>ju4A z+wpehDlVcYRD#uoF{~83(Q4zKLq6_IpHPi6*?g~S~vx8clpHFPECJZ zuCHsXuBdk`4Kt`{*9hM4xpBre?z8pu>Fb|1WMvX|JBbfb?&s_nnl+k-vmZV=`fhoD zXorMgv^P&&+tp7cQ<@yLL~$!ipJVbpv1bVCyaP(AZ*ktlc z+>~b=^{(pg&~D8Su${2EO+3aM%i*3V&3<(>#$~R#b{aOCfVnNl>im&rU~5dl_9&*$ z{H{Y%O|h;`z@x<8gHY?%(FN#I`Ev8^(H9{eM1r$r{-)oq)-WZ}`^AFuC3v zy3lR3@LYE);pI2aF-Hv&nH>(_u2OvYRPB(?FO{)7FV$@?l%8=b$X=>bGEU@VaJAXh3f%5?kddr!7)h*ijQlluI4*Fzd4&EYpX4GDf~GDR3Ag&$u( z6mlYgl&;U+5L{Iyxp4ce$=vmnGqCMzKH;FoA&o-(Wnq%>eJ9#SA^NUjCHNK%dXS>( z<}?F^#@o(n^B2z#zd9&qVdTGwB!8T8%@2je7(eW0Ji3Ag0q4ofXxg=_H?K1ub9|3d zZ(rS|bL~q@cg|#Ae|uIs2A9O?fsIUqXWn6bTxBor&l z#JUVz)FN~qPG8&_DqL-SB@3~6D_^*`$c}Y+v7pkNAtR3BEFV<3ud}Kef;yp zCrTlryYr$8-1(_L9=mKkQrys&i9_4YrMBu+*xHj%qTf6s$5p_?U-N%t6PnJpmqz`w zQ&0%&N=*yo$+0W zWC`StW9^IQ8xR>927jb4eJ(H9+026TRfyq-NA}cWto4;X27hwy=?2|1?80l;9ySg3 zmil3xOCWplM5B!jrF)|yM`&K`roUj>coXnrHy5q^D-p|M9K2&IL4h4n_WXPNxF4tD zzxFeX6`=<0`o+D^RV}f#*+9C-ebVGzaML)2^+^>OFTM^+;x77xm9X%7eFL3!CbCVp zXe3=s5a%AGeYXS6=3{w)Jr&W%)%q{!VIYG&77@%+#B#!2#=`W{L$L#XDq|-G4qCab=Pgr!=Y{6$BcA^ zY{k1(e-|p1%{s37PLAfb>CkL+^=2$dqo^?-z1&$UPDKt^Sd{L=kM!lkP>D2W)dlXK z_Ok-=c(m%*p1!N7N{=4>X^#=zp+98FoD)Kb*1hpj_&$V)_}NXdxX&v*mMHc}tcvM` zEcTt5pQ2;1*=ey=f<8U;|0&z?@!&GFc(A;W@M@0rLe#tXzR4{aQa9g zgNsEelj{?m6!R%Qn2y<3edL8iZP;Rp(j9!ArxD10`4+ib#5^i513w+6sFOS?`E=j(WRs_9 zu$k7+C8UaBtaWbtmQwG{#z5gAn@(?bn{7ptV~$v9J^}#55>jX0s0Z!lK7AJ@KEh103&??=^r|O*?(kRgmK85wt zC?<_wrPt;JCOTeS9}znrh*w+?$ffZ@M{n@ODg4CmJmFodIp}60%OpK!i&i)BaI1H= zD#Y!``@v!gq(JP7I-cY@mR0SSDuYGFOpp1a*`8SHqn)Fwdmn8+M_N;Qh2Qm%5;K-H z|MB9feie|(QN|}FJUaw4?)nXaD96o0HA>yeeCW;SCDf95-@K4}S(4^f-^H5hC%_Ei z(`DT@^1eQCzpvS!b&@1;F4kpdE<)CQ*Q4&dU^rJlU zF@^C%1okm2>hMCjX0%0HU2-!5?^{Ib^qx1+9_RZwt>5zFqfRlLPE3+^I)X7%V!XzY z-KwOa&QG`}Cb(hb^FS$Eu)n8f#al}fW`E&`Y*E$0>5w|`zPm?hJzf9Wbr~*kB#8#g z${U8S8BzzRriOfXYMuT1uQ?^zi+8n^Kug^drPyxwmrQ$H9iq)fSG1M8CmWG=J*+1V zEegpfD8|Q4G*8UqFd)_)+!iU?jUnF2p%Rh&pcG;)YaM!ljdG0l-Ug{h{XhAc5 z_%+kFeQA<3N_ZK>g8C=lbY;F!_gT?MBR6VKi&MH2q(<2|CH^R!)YDq$mJ;wcusB}n zC`J9eCg`FYlYzz&7Jj@!bnjw2@MagAL-64zoROAIm4&lYP}yPxd3fy|Z?QXiu}?G*zTQ}N_#SYR8=l+(1Tgx)vi z7Cr?WDxxS^dy8#NM|6whcaCz%XDm8-W4=u=bGNw@)?2mby{=p^sxRW(a?c#B9eOYK2;n>=z2ElF<|X4^@HR(x&!jH0ZJq}ka8F(psD*-DLZ6n!Tg!=A8r$FbYz`Za*$@5?~bg5 zp2(2F3uEfF;<%b?_f@5{>!H$#d-lFGJCZ2W0fXsC@iQZ4F8C2o*Qes5$FXHZVod16 z!@Its-qL(d%}(z6y}3Fb(^b-;(bp_J?UU^u<*or^5Th4PYi*Mm-|vNOwv?o`>B$LCF0;$ME$Z_isy(k;**HF(N6 zl)l_8vqgbFA;^rWENS%=BKgzXB21S4ZHxU9u2aL4N$K=UAraDJ{G|B%6!++jg@M3W@nlD#vWz!3t7RdlC1!|mg{yp zd+(Yn%1&jfS#8~rciMMrT&q3aRos6q%O|M}Tr{UDMy;&BHak;Ryd6(Jb@N_~G5F-x z5vtp^FLTQb##Bscb7MC7&JYiGA@cY$pS`5^yUNvlVH&BYU)J9Dy=P&iwyC;@93*vX zPSa$v>*wo-Xn~`c@7C%Cj>bKwCl7qv~uowCqo_Cw_@A<;*p(yg<@C!y_5>taPzQC-AjMJTBA}c27}h@SGmc= zn{?i_R(ImvkDfeD!B?3+Rvq*)PFKrBnLnWSN%vI!Fcj$mAx_Du#oVK6L0VYg#+>ga zlsg7;6VKS?Hfi9(&|aGa9&e~q;wrBB$m;E#;l)um`cPz5w5L>fXpWy+m&yBL#r1P{ zd2e7v=lMo3-rMw0!y1g=tJN1H-$W7+Tl zExFHfC^5UOzJfJ>li$J8~P^#i81Ok z0dwLX1}d*b6s(;rdyU?vkZz==^!*|45T8^KHud>gL8fB(gb&5gt}ioLM$1k6&L;~&+#;h{jo7o^v*mdd3wzh~Kc2p%ORuxyWvxkBAZyKC z3=S(15V8I2lu-;JW%C(n#qnIFUo=Cp3dcFLI{Og&YG=28{rzLo#OG7HFS9zsWp)X! zc0vM0K5EXrQknf>Emqv??!Wo+!#T4Hvi48eu`M zK_^B{#x|(`lvI=CQffT07ge8vL(#r>rnRLWxlOhosUJUR#0@Eo80mc9z{5rX|3-(} z&copx?Ph6y?1mKo#k%(#-FU$x)MOX6;U`E%ZilEG(!>Ke}MO zh|u3|@%{hW`|7x;w(f5OMN#a=prl5aE+m|8rc*#CnPEB^MpP8VM6tUY1rrrTMMcE~ z1q%xc0}DG)-^1v=dhhi<@q3>4zh^!=oZ0c+Yp=cb+UxAS&RM`ha^ukc%12L^&KQV{ z>(r~`z`{e=k>wo^L=VKJvO#ThWm}NxE9P7ra=aWiXLj4%bDP(nu5zuPeN&=?U9u&P zzuAIc*ysy0O~N!=kRJ@lq_O^)-( zoCTEA&5H|s{T9RCOFoPTQOjmM8FskHGT1kK$hI*aKV{I3o^amPxh*e_0Ao6B4~sJo z7~NJ;_`*8uYtzT>Eno#*sms>39Y_2;q<0ml@I>~_%n#kGe9fK5l^@+2@_uvsL;L;K zKF%RH$jsx$i5qcbmGz)^mC!k%5vPwjkX^!Cdm-wZyc zZFvp)a&h7Ona_MuXX0aT+*_y5M4ly8Hp*6(F0aAATI0O+ zHvP*QP}B6%+3#2jhrIl(p7!G7dR@?E{mUmKKJ6)MxSe+AB^0VGUEQ_p(c21#ZtLO4 z6wyQE@XA=v|bKdxG{dt=4EA(!=e zk3i){zYml{OS@t|92hfa?9@a1JLCxucDT9B_xkgZX5xyNOF6U&lJ$@V|0(GqeVVoo zRQB33%8uQ;s@>*eSEoX!>@}v}8di~zQqrTtogv-!wPw{G#|Er!C)#ynN$vF6{pYo3 zw_SB&UjGAv=_i^3x17e7T-rUgJxj(PUU76hzo-4wf-clu8~mcDzbb{(g#qZ|;`B|H zWsA>8j1S)JEoeeX{TDoMCbxVS}+-|ja}^ZL2y5~N!F?;m>*RpmY@-L`1< zm+pa_RE&McoUg-dnVE%Y+7>9oZ|wa$bzMWIK8Eu5?ND!>}bq6{6ZjcNQMU z<+iEV7L!t*S$*i_g~YzyMV}|Psi~z_j~$Z(Qpbnx9A}Knb8l*Of1rz36gg^T#dp zhToxg|NJGP@>9qayu_!Qr9WrNQAK`P1ZL&lj;DK`IKR2oxUXaP4`p3g<_I6~f-y5k za(Yt%e}iYyW7!G4n&97e)B7g- zZ_@O}ZkhikvG7QfGr{NU#jnS{gd6?$9Ujpqxu@(dt6$%O7+!dXYgOy+Z+olT4HJ%T zHwKjWdB^9?Z_WA-3o=92hq3o(F0>39Jbp_Hbke!>r>-p%DCKP?!Scd~$6&ITSSH7b z?+xu8Y`}~>a#0GsU(6p>-#fmz zXRuFRclFfn_Zuc&$U-=0X(mjGcr<8deJ{kmRWX4D(T&}b2hT|ocYU9@xS`KSKP>v$ zIl+$BgFCDkb+1buBpsr>aBuzX?$tzkU{%?nx15)&qK?nZON!XIbm7PjUF1T~jjh>_ z2S0z@Iz5i?1}8TiyfOLsgo@+gLy%{D8kOUVGexV{fX7WuyjaDG*too*2@zLsBgt;f$qOo=ddeiA|Pg>@)Vly=LTtyf(8e8qq9 z37@Rc_ph-l59~1}pI_1Yz|rb6vd+1L55q(w3Hx2S>-)U!x4U6mo3ois*II>c2u)Eb z*%=G(O|5+)z8sEy{V><(M%Aae9oLr*nS2j<=oGEA_$FCDV6S}M5CMJSgL^M8Z5VBM z%%n|@UDS}muMO}Vc(wg@tJc?t89KGu7W6QqUrIy&Y|VNO;l!1p2j@QR9#(wn;=UJi zOSg8PSkrb&yWV5#a(js(>(;ahI7kQYR#NgmwKbIOc{vf>GgOX|4wWye?$v=X`g%>r z4U1!}4Ly1!U|wDb2V(DCuQ znY~6Oy*M(`Pvu`@4*p|7yj`rdyK1(+0-P^bi%Zd=bL3R_S9O`4+A#DX+II9<_+uC10C5w%saP z>f%LDNo9gwoQlKdH<9SNog41$dKA8P6KL0m7p|mzL7GANM;0wvfUo>gcD@(N`D_R{ z8_6>D3oe+GJu71)PCxbh)fH`WSFHZH9ONH3Nd07Td(ey}U2^9-p1z3_U~XQkhSg*t z-W+&k>yePE-`{Wb1Mc8Ll^*siC~!&pUUla}`t-OQw>!*e9RB(A@&o3b$%|%gs$PEL z{E?wE>vmSXdf1Pg<*qsWJVx~C+}-@mZ&u}Y8ak6OF4N(^W?FUfCD#gyuLUuE6y3S^ z(#|cJ4k>P0U=`vqvvb`KETuEk|d#~ zy?@@O`}vOwcCXrbBrCq+Zt4#4S;W3eQc`PE+4Fr-!q3N-@IN=5POI-RT>%Hh&Ky}# zw4+GwV=6n*`fFYoX6D_V3;K55Fza#l&Pp1>EJsD33l3h_GV9r%JM%k4yojCle#4_3 zZTrvYx3so}9=yG7K24H0DQejlsNU9URQG_&h3&Mmb(cS{zGg3tIBT%5lAP*n2PiLV~4 zxSYJTEN|_u6JNJgN9Qf4Ze7=ax&7u-Wi9gd8`^Be3^DvIEMw!g$;aokD%m&v(6vX8 zzw%P~Aw}Zs7n@GK@f%)On*9C|#Xr^4M+I4sQ@?{-MQs>w| zJe>ISP3Y{qo;IfJyGgT`JUi825R$xg*3J_-BLpcA?iY@I-Mz@iGN<5H{DBTx$Ty>2 zed+rCfO-4<=k-t5HH_NW=K6^Etiv}Z^-l8-{qo}IU`ivq`-dAlnU!czZ}{P9WkKPi zKFkWp-?vp81PTebXajxOx32nA+XF?y*#^(K_3ctG`@3f@oAVl>(+@nEv;2{Jffg_DKPO+BZ#kGO3#UXkOvk}aPe4%3{3a!TxTGFD207xv(MiS3gWhR%a<*1f0P z9-VXZ?N9=#VZ@bILkZ5M^{FG?j$c4)Ij{aLPW~l!Q10WX`H_3qM(gXZofRXFHns#2 zd={fmW_L{45r+w@Y4v2=%hhGR#{`u(#-6|4A9Q^o2Xl$5O@WLa=v-G?^Jf2}vAVte z2F(cH@NNktEB>Ku$=c8wmEmIisiM}$fLFP6Ux^%)+jaSo;v!kZ%H5ZZxB5MKJySYo z(bDyOmJOI=?{)ZLV3@_XN4*%^UOc1C&M|M!o$BCPysTeVCUfc?R4zpb-r8r_ETMZvDVvEzjE&&_t7SRZq- zHl^fj5`6fapbnbG9Ng*rmGYh?2OKvKg1cM|ecq5HsjKt_ja?QT!E;b5vHq+l*{2gi zQWx>>g~xL$kvp}w+V%MG^i!Dj^oPbF$mM}e((*yzQP>a11vB<3{6H5sElgFEExs!C>gK5hLb=G~K*7L3zeZek2ubJaMas^$di?8xCe{f&iIwek`u;Oj2V=KN2Z z6LU7*pP$!piox1XaPrB4Tln4u`Dg0;xj`d*Xij1^J&^TP_hDtHY@Ep;pKx08QI%0H zV@w_xl6vxyB|f)B&Hx(Iq3Jh-+Ba=AzN<;xvrk%rCc^B}HtSW{Q}pq22c-RCEcZ8yia zj5gkM&?jo_*&S+a(8lC<`pF3^&Fm^w!?3)de%0FBaxluNx z*VXQ81G;6fTA+`)Ni*gws;!uKpw%@?5C7o7FApB>2%XjK{o!fz(Nmv4RWx0BIDu|r zppwP}mdt*#B)L^Wx2%uXseZ2>x$Ew!rwm`(Z=RTr&)iOF+hh5_yV-NA4mQr;(gzHl z_GN%7{?+EC<^1u+x_bGPY-@!EZAjj~tbNB`doT}A&F;`8zO;WfhTif%otJhK^ssl^ z(VsHo+il8hZS5ERFyrB5I{5YoYF*e@wCLn&-v?VLp_<{^oHZ)ThVU)v7q0tW-}|!l zF-YvEum!fkz5%g@HT{;y_q(zV6R?kqyZxYk>=tp_s2z|VpQAB2&iFOLhJxV@sTXVa z$)0XAeg10hHhy}m3m?Zc&hkmJT`qYLGiWPv%IvGNb=_D*Gq04J9pWn?p5 z5|U71z0w^HlnV@P|#jMxGEZ;TmnDyDDz_-Cp zna`b~l1{U7`c&v!QqK=xzgv0uVN!y=MmJ%7uT>pi?dX?JyD ztIz8OA|n**`(D0lo{)I3`pHV)H#vrmRpXNxvwSP?ujvdS&hqqZ{MkpsF1yKs+KYMP z6K$2JOHH#`#=@gx>vmD%KfgjZzKWQ(!hU1n<3aq!*5@Zonp!fq+k=2t=j$_jQ{Tp3 zWE`E`SABaGXIp0Ff-j&ht1w@J1&c#@h-8UYf;~eYu5KUHl=Lsz*Q+n z*Kp$t){eV=NpLo~`qJuM+s_DSIsG%vFS&al7S)nvATp5JBei{=-k&>W?_4mkLzi83 z$Gb$=?H#o-84u?SOI+%DHCDs!b9T9FI&xXOKXr?_V1lz}NZj$)vR>79W-CIKbFZy< z*15Fb`F5Ad^#_ivRxBNmk35c87ux2TZO*_z{Zvmhu7BLz((Ez0y~i(o20r^BY=2w& z+{ZavTGpPkmIQUzO#s)IthoPrWtR=>K2aX15gkrx%EH#1E!bXtE+sSTWwL7a)s}!+ zSClDEAWS*XYEItz6jfHKJfO+AH9KSc)3iY7>q>oO^%2?Dg@eYv_1l~4X;Tz%gE4K{ z_~YRbMNuDdQ`<=#YvO$RY34qANG=u@1_z91EIof={?5|*?Sz*fZ>d%6BCdV-zFQ?Y zsbAkrRZY3(NYT78ecX?BnlEgdeyQ&rUi-W()bg~qpNI|cV}q3UM+G%qdAq;+?jd82 zofw2CIhY%uH~8n$E|E%Z_l$hE@jdRtoe}Szq$qmJl^KOCuYE{14U2hqEa&WZ>8dnh^K zMW?eDEGq0oUh}2Z^x@+|7W#ZNK{j1D`+3ZYyYD7FID6wu=$28Rsxiz#*Z0JPc9Wg> zd~>X0RN}3vZxqoxsBco2-;A7j`l=aoB;yvfQ*6Lf{M9$x9kqt82g%nCjaasBWVvv~ z=r=o|op)8vzqTT*X3Dj;`tJREJ)9IU)VbohV`&+5Y>qxJFJ?jh6+sv0kx~6S^?27P zTvhrA*X70M`-SgCrOsD}Z;qx^xBW7;?J7#rP{@?Lg`W@BtNo3~PWUWJ2XG2i=DXc_dI*`W%(~(J{#C(!~7xlD{?cZDtVE+MC-4tFKjz= z)fMpw|EGf^pO3D~yHoHv`2N+at4gX<7Or(=?VND)-OkCx0pr6%uB#I^lx9Eax8Ye- z%|PFX6|clcUyK+9vOIs@{{7ZHtzYF#;BGq?D(Z*eb2`OH}|txky_Y&ldb==84h%$eZ)&NI5&E|y1! zm?MnTa_O<-HM6EL0{U((z0FQ^&T+RMG$*3*<-i)%^LH00&fBHX@i_aK)(5-1tI(fX z)1hzD_EngDoR?E>%z0II^X(aY;Ly8cv02ONQW<44$Eg;KpP4teW5V&;u0{8;uc!Jx zexBK{dxzj-kkCu?J}~}(wtKjf;_oMvPq|@#kPS+i-q5vmIXbuH!P?Zebq@ygX*KH1 z442eB-rVLrj`a2{F;>%R&DdA>&c3W(2>N)?mGNqO@(ae`mF*7q`pCNP0OjrF5sHYR zUl8MJj&`RckETvhLqqwU&>^?+3HO(0>{z!4Ln=tG>b(~6?ETdt5901WQf#`HcJPF2 zsp`c%U6+CO-D3)vJES+Jm6qc!-z>VVTyUaIi?46SK{n6r^WGdW*|u;+Uegrn$yf`?(3!!5TP{8Qs<%%U!q-y|!y|XJ*mLbt*A5T6l`T@VYxyEZ)t{By zky>4z|M7YbqbushZr^K_VJ|nB7j7MtI#<=Ab(yl&$rINRiJIKb8=s8cxHL$1w7Au) zqq7M!2l8%=&wH14fY7U#W9gjW@#%y7eD}_83|cq7exh&hkMhZePWLnOlIFZ@0neEm zF@0K7T^=!?yzi#(O>Jz{JZp~te1{S)BCK$oMHpl}Upr+;{=3LNckV-muRmmLC@nL> z1BcHrma!8e!fEdVBinKM1}@&R9b=;(%f0Vcvyjo zYxO2ASde;HnNLZG+eE4CVGCR`X;cFE*+h5FsyRo^6Bkc(Bu%>zI6r<@%R$dq)dwrHW_fr{@=c*d6vEYXU$yo&l>Fr){!~o%>@VwM4xsy_->lvryuJRI;%1fY&N#*H_@OI`MTbtT z6x|KWc)WHArcK4>#SQzB%Ba3`&u!1dl27}g!}rgdnl<3fo?RPbd#Komu^JQkIh*jVI1`~$& z8(6fc|K#c|9h=i%EZiIv&Fq9(Rp{UR^I@Nzw}Ya6S@${zUpx(Z*|*0nMP!mu&4g}w zeJZhbb7|x()s(O13Aa|oSJgj$_IN*c)~7zhy2ia|K^$4bZHZO5?vS3Oja?)i4Q*gP zIXF5O&JwDHB8_O^p$V4O8)kK3P3zy`dbB0y$I|3=z&@J#Ox^y&shHQ2i_`|M{5wvTmQ)u*s5X zZ8e(qX;b7QrcJv1xH0td`s2zKgFTn7Foq)Q5iH(-nes{;Uix2j7tT_N|=C)iKK7RgA-vvJB6fpN~g5nxAfud=`Rn0Uay?@YUGgD^fA|Fp+Y_gZ?8u@%fI)*RB{o@e!1r9 z^wPMd*|U0wK&)%;Jt;*@6Zb95u<%!`#WkKw*{v|e=jV*dg6@25oONu(meE5*@z1vh z%n1zoXd2sjz~jyLb{E%WHyMjk66Y@n9)IS`1%C*k_8plSTK=5uh+r1L)Ac&P$k(o< z0fiY`LiycpUlz>W27jLTq35f#%0cfZthhe*VfWec@x9xaEEV$FAzvR3QUu|#?`Ejq zhW1L`-(^sfgf^l5%#Rh}R0m7<#xL4TzTJ1@>>=_g7_@*EwL**bZ%#d4h`JnqcB>#Zy}Nb< zW_Q+tk~>&_^wRs#YpEwq{ZK{O9|-8FA@x1;K|KerxI?`g!j@D-c~+AK^UQmip6u8& zzeUrFCg1jf7vq;rJ+?XdE+gh+tQj8#0a;i@8>tV*vRaIpYVBTjumgSP+l0fx>{<0S z{>RRBI8oST&4D&2R#jd*8$TYq(SO+aiQ@Xuo2^SAr>fSJ5_>&|VOYlwHy#QqC=4C% z@e7Y>oEO!t7SuZH7PON1PErznBM^6dPfOXew8WKX>aeGlf5qWh(>E-5lrf(Z_HIm{ z6W8x7nEC#^%)TbddN>r_fqHw$3wLPmgZ-0w4qNXeK?kOv7<8|7_v9CibHgXTelzz% zXu+5T{)ZC6Mh&@Elk((NeNIN1|IV__IlOmsI=r7{?G_cg|Jd@Dov+HyJ$QU)h<0j4 z^_@3uV#4KDQyV{zBJ9t-yfoKsh(0#4a!tu4Nl@!}(frr+x2(RTu!$dwkF)a~fL_y=qB zv!>2%T6!>I_L@_bQv{2XuMOXy_3l#9gO{%!g%&*>IPhlcC;JO^Cl-Vk7k3#}_~zk& zi)u&9n^y;x7I%p-1r_G(TDG##e^KXZtXSt)U0$~R=^e%X*BdAJXI5$s(yxH5yABI^ zgDQ{AK6GVg=fN}kn^>(MMXXF|JCSs4&WtC;^}4*ZU)|+*m;3nCQ=$&^?pi+S#-#y0 z#vV9xw%hzBZC~oZUQ0|97i8=6U7s6HV3LpvzBWNyrOa!*Gnl^oc;;HkWJ-M$>V=zn za>T3m`@RnR6o7xBdIcJ282aV;1nQLbifr#IY*?2)8GViL+LsBhomUhKMAlj^=TWNTA-Wwqw4;80`0lX(kAiA!d8 zc!uX6nDiD`IN1Xmt-RjmdSTbkYc1>#GluP4yk8?+{Y77zblFn=a*=yLKhIj4g3WBiMnv8?DAJi>6hLczhK?V4xKIelIb4MkT;IB_)vhQ z_2B#QS-$6oUR}8N;Mmw1jcHdC)A_qXD|9u_?nF*0P6=vT+bFaSYe($c`QyXDp644b zZF?4Knb=yu{JKL%y?H$HPSZyEY+vq`b<1ArPF&oa+1uFwy<4ITC+ zl-^6-6F&GnBw9$rK6HQJ;1ItW}Z{qPTyO?!j{hDrHw)w2D9iQF@0<{!>*;W#C z!8DFE@uSbe*Y}@fN!f)p|W4O`nBF8;{cJx_moX?`U2?k%Dpsc2r zLi@BNU~2v`_JdFLWW&VqJuDu-K_?xK`7JJiwSB{uoUuj07_o|GXb z#a#41J8w4lMc$i{MfFb)z8jaAG2w@fX=c>XM}nHPvs)f>jJ#1leNL~NYaoYrfx_W)v6N1rsA=Q}P&&geK*1l20j2GuzMmSB7qA4_& zwHVR=qWD(3YsW@T)u$9K>D0OZj!m5&oLpaY_Iz}O?AaRazOHeL9$Ug22MUlqzZMHG zzC2KNzV-9$4a0UkkKOkSu;HG``;DvIj$EJ`cXamt3H|pCJQMx0eV=i{_roTIWR>5b zZ0LgPwQnkU;@}*pS@nUjF?45D%(Dr}Bak_5rUooKv@l~B++cpzShIIZ&M4&Y2T^TS zXEr{XcBt33xY^3yhv@U>2a3ZPIKznfpPp_wY{>eeM5R{Fc(ZiLmY@uNEGtZhyL)wL z;)-*)S<*l>3N%I`(C>@n=n( zY}saC**0MDqJ^vbbnH0{dZnu47F2Iqi!Hr2A0BpWeMH;VJp)z+biB84RiBQ%1GSLg zh?Q;E__V3%Pvn%fvG}xs?H2{X{rlZ53vH2S7GBIQe_fNmy8KLk&i!SnOC|FQ@)!C} zuP>in(`w~hyoZ}^rH$bh;H~5_SgOz3_C+nP^myUB8`Gjy#}<22J3ac96UUDz-)s&G zi1CeQfs>l!^BTYeTp$pk8qDT=k3h|5$?bX|)1(F~fiU7g7LLaMo@cgvzwm~a{yqFO zn2+%jd~P+Q)!H7e1!v9}@nk{&gv8JBrbjQ{=U0E~+*$Xb_?{qrM&-~? zrW5x^eJr_Gy!OMj)VUe-)3+NMn_jy<+Oyp8hfEtzHyVe=Z+N<^f5Pa~kM6%~b^X=U z_CA|CEf|o78*omXtZH+|=gjEB;`Qf7hCpnOvmZ%IiVN3<;4K)wg_!Y(Y4$*Tp$c;yR#6- z33xBsUNE(2*18){{Vt*xOYP6sdoIsgmn=+6U++oe@1~!Hze{~lfJ( z?d+Ae2Sy3VWgj22lYQM{4tBmf^8~`Zd5t~sXwNIxOucFjPQUml)DX?gJ$3{38Qo;P zTiO2X!Be>b87nd>LYwvw`tQm_l zqBO4yD#{_|?$^V7=GS-NpC6M*c=>tsfOkjncgq$Guuu8aknm~Hn#o6b*XIfH)>lp; zblNHCml@|#fu6P3HTHM!OUkf-zs|gWcTIw9#Qd><+?t(@j938e>Bc0PDJm#FoXwS~xvA2qA@M1Rgcv+1 zS!hVZs#xj(5QI$NHYgIJcyqfnFx)f$q*{1WuzYoY6}7niGmD|!p6wSG#)HW z5lslO$4LxGGIiMSxcHzDlqk|k#7j&xTMRkD%#)#shQTbRG&v?wmB2Fjp$2ndW~nBL zM-B}Iv6JFJDLmk{egp_DF5SS4F-vrI*-*YzI2?=)i3@|HZAt@|VO7)BAwz^@G%rm7 z^%EtD$qY?`i6}#`1$GEO$S$WOP$&$cfg8n5L?B}cv@ircd;}~#PVJ!62I_puRAHpJnD9sqiDv^FKvCi$k=9gVlw605b2^!l7<3vq zAymS!DWw*O2_!L$FbSjB5q^f@p{OJiNH!wL3Q4h}wf2;e5((5K!-QcG^5iiIzY=Wp z?}Gh3+&m10{ucxQj0b{(bJR8r_Ahdng16 zrmA&mT1WHc4^@j9Doc?gO>kRMs5Oj8B8lU0T5?K?8j8}VS~W4+Xak8Kg&`QkCP;F! z9XVVCi$TQ34~dOpq$C81A?^QP7YmCYPj-4pmVSVg&}- z&|z?Nct{#HoEWS|;E0kqsA7aE$q@!)u{lBEcq7a->( zI5I72u!O|~e;*|%5MBuZ_AY-cwL`7~!r%O0Y+Rxn7zMrB5vXVm;vFCSeE*@~K%FW+ zSd3&rSQdg>tD|~sYQ85)p!DdKSXFRhf?u3F)@`&H0klB3(O|O2y5oc8Dzicz3tTrR zgM$OTMI8G0V0a8P1{w{-wGB@23k(d5v#B((JV1+ms|oyy57s&ymRK-2BO@aw10G|x zrGcS9q+Kus28O|+ffCX7Op^l$OJlN!d;|Fnhp4tIZ8}SHnE1eET)D#Rbi@Y-H#hYA z=UYQ64gb z2J$F0BhaVsBm2vWX<(f(?Tcx6jO|wm4HX9OtxhBRnH~oiVKaIlgS?cCRF#!gF zVxu9LXc$ZY!NkI_z`tk+A{GMq3FKSJKVbl?s!o&nuVDOC^3NFGr`x}T@^jfg;Rv)& zqrxQD8SK9e;y3a7r?S;7J{Gynu5OJ3Ra5afG2YaCF%`9s2m zg}8A;aopz0z=w;&gF%1_)|2%L7~O!Q2)xNS4OoGr0hwuB5*{Zc;nRd zj5G>9EsaUUnHhu(Gs6o=#N${*Tt@SME)j>|kZ>3_5zi8k@mvAPouQz(fw)m_jOO?M z86+w`LzzUxL8L$nQi@i{BlFDOd@tbd1%IyeFJ6N&Ku5W0cz``pTn35vt;;wX565Nk zfUc2na&PacxaQt*N#DBc{pON!88iyO7AgU!2f7F3rO}9gF82+Piz5KN1^A+*5x8b1 zP?P{PK*XhS2|yaa5dg@<0tSo414sdEFq)eyB2h>%l1?cl30W437Vj3jxjGKs3n+9@ zwJf(xhavDX(_AD2*TWO&t&B`5oI^+wa!q;^+mobK31w6P5#prL=w=q3piPr$=>Y4j ze2B=ABx2xgnFuh4wZwwf zP@y0VoK4b7x6AZ&8JVI}=~ZMQ z9AjmBGBVRFUO)(l#&L>SRWXQ)!Z+GsZZ_&9N`uJ@h*aB=6fIpVHDe(tN;(@&&>}?C3>(DaRDpE1Oq$V&L?NtD zoy{&%{gW>*;IGPAX?6t&WM^8KPPdb;5TnwQq)3EBo&@FCGxa2?6=Zc~62M7L337hDMBKr+WcORyaE&$&Huj#4;WTmQLon(NL;OXG5~-HmBOja7e9avgBX-@&f*< zFjfLax=ktrm<6KY^mrQ^K_t4#DMg`!lNMB*flK!?^#tyl$^=QL?B-!wLpNd)6qIw;tpv$-G&3mn{>lF5{S zj11@Zg8yn6FNOaJn2XK=m@hHkcNPP`79igX0^)ICMF!`7GW&kg#;@C zM%xU={LU;A4-j?R-wKfz@Ov)Q1or5#U?fv60Xxhd00dzdshx;l$e>;VW(zrBy~FGR zfYpNa4SdR6X!X<+`u!~c9>ynw&ZPygqE@dEy=&;HDlf0rFz!2eXK z{!D}y@LyH$%@qNo;2*N%7d-sW2F45cD^uj3QS!%uOb6pQ9s>~TQR4+PxD^aJ)Bt7U zMIfoxsKD|OR*(Sm%@9%BfWmARMBxC*FbW1k%VZ#A9t1&W1KHEb5);SBcC$?&sY+-k zu;~sjAk(SF+4u$y-ymZM0oMr;|8HpiKIuVrFi0tJfpG*e#09)Z)dWT+xq&4{1(+XR zz&{%zE|Uk0qi1@cTAo5J!BRwNVinSsDN`c|S}DSV<4HK2rkCx&(k-x43>dWh zn~kj@iTF?m){FuXJp!IygJDu&47(W2@$e`R69%G^;KceQkkDloAap7mU>eaN9G%VM z`9ADFYpXvE3ecGhqKS+KXEt{RM}f$I-+DM%X9L2}|Frb{)^~=>W5d9iNHdtl5h|Q` zu+R%=SFm($yBKdJQgM8Wl$xRDIy128Ivf7GVW|rjBi4k<8vW(5evho{rg z76nZpRJ-NUbQDX(b$z$KJO&w3%z^>t8q%oc+r&1b+eM*DAV`LbBtSC|QVp6(mUv|U z&|cSwHDERq?xhg0Q7jt3M&ST9iq0)Uq5o3Tla9e^Ja|cFIx@+ibI8;bhulta*l;*7 zNvGGiWG-x)1qRN*eY3s*adJDpgUGEO7FkF(N+BYilP`b)RbhNpl&%YJnmhtaEz=WT6P@#&{`oh~Y*>rpqeUN|JB}ECC{Ct3aeQ5F-hU zO+yfYTL@AT*UoeaY#^kanaRq4t1Ju**-e6rWHdk?IA)q#MEqu@c>(`8jeZ-!@6t?H3}s8bfMRUA%q*i@1PG!T$3~OTIF(0dLjboF3J=^$ zHAy@?rQEF&rKf?NHUgAxr72W=H9-Sx(NmoQosIn4EH!BKUO*y}$uUZR*+GF|P)Y|I zA~#@FBC<#ZQDEIvgoaGVh=@>vi7bI7HM3g70%Vk^$ATnivDn51DHRkBn3u$5WXQh( zk|~)If{oASLePAggJX2*U<+jp(}I_0 z&;UMI2xJ~fD3TeaXdDh0A0EeJ2PdI49-CEzVnf`pbc{nR&}T5@R)vnM@B*qGG?x}k zBSNSWC7R)4erJgSN2lS)d{r7=XG3$qbY>o5mC02yfdq5t$+HO4lFaGpay3e#l53DGv4;v~m^oBn9TfdO`@Mi_;HD4h1{+igwOWM( zLzGOi4XUGP1hzCZ-imhMX&R)&;s7&LV2ag-fVpf8v|W*@&U68Lm`=0|rZZ`kz%~&V zBIA1jIV37sMEr#U{nOHiW6D{;H2i}~vI;I+J2&hRpOdunpGWjk( z&?(?O;8YL<0w@}NItI$|q(MC%sG34j+L&CL7}y|E+C)f_icZf=GHEiXJT+Wt5FxSN zZhHZnuVg^C4F-@zYyqh?EWTW$(3))oir#3Em?;pJNDWo#&{#4Rg2gFC8o5!2O0qb0 za*-S*vgm0JG{uunQj+*sFJKxQ$x-UjdXrU!0kq&xy2M4(crw9s9mgY)^W|8xo-6Ut z6*RVz0U^MZajc9DFq%li__mn_VcA)>3=a*Wv@6JT8XPY&$OK$#1{*#c0t*ld0$uCY z(NPqHQiM=*sAzzm<_#D=2Ci0uSrQ{$B!+S%Of#J1w$h|pFCc~`WRSp8oxp4b>(mxH zn_;2;E?6==l7`XCk;qJFCX=tC>#Rr+1L36DP(qSOPT_OY9SEXTU}piGrRu*ahfIOl zrH6BADk{Xm(Mk|#Be2l9=tv!oqSwHalC-eqg+J{tlMzV-DS@u?0NJ;t8=xL8iT`cY z!(=iT9v+mygG+H1o-;F3h)RN^k`P*8rvPp+sbC@~aF?%V!mv=Bn=dEu_%?GUn#8ie z@oo|CkL`H73>Yd{S@Tv36AsukzwZ#>=uTicwrGq*58W&=zy&5KRcwQ?GwleFU9W{p z0R>@YFzjdzl92|ebB`7zlXwBaY`)rtveQ*b7z;tb$8zLWj+H1R!)a_&hR}whfh2A< z0tIaJ@Bs}BQ^A;QH(jQa>-AW^Kxh47q+}|-?Qjx75||65a>yhC2e55Q(u!;_tWx7N z(=s&Ij$At2GcZpCf7w5>*yA>fd^#@)o3RihZU>&CYXta;dm*8iBLqc znD;~3{A^BP5a6VAj8R3!V8kAY!R5AVSVBG|&6UPUGvH7>I1Xpcz)(;&bh?5p`9`4t zslzzsP^1bfSMVJ&hs42zk-%gMm}lVdAW{X(f@USzMf^XRZ&)!#3pS-Qk#vlfV^$kr zIA_u~9vYk$8Q8``0y@u(qFEs#L8i);hEfob2rC8{ESAaW@(_^*C=R8y8L&)^gl==Q zv>-B01Cv6?B%VeFtRH+}T%=ZSm%kgpVvWY20twkJwb};ZGl()dnUFz-S_SD+2pglM zabR#TUJaI#MOYO`Od`)R-jvbgA5p2VwXYfJy8$Q=0Ufwt*-zI4Xf1P)l?PG97GSB2Acd zi7W|8#mXT@o-@f|)zdhZB#GIchH|?ESUs2vSRlVN1-uk`je>7>9LX-=;uOuR64ni7 zn7}3~8w3?*pxnp|9-1jgGHD?;6;mfwY2Yrdo2DV6W#CL;m)2!byX{P-;M?98o04gl zk{mWZTu8^`NoK%OP~oiUQYo;2{L(V}ao1sy0Q-~@U_q6`l?(~l#`02V0p_QOi(*?) z)=Y=6c}rQt@i3b=bvbCbh(pKQga%|1aQ0vV^Eos(7{LP!D<%`LQIQ~*f`YLtoMMkl z3QStmH}$1RPr@@SJb;xlv6-i{YJb;VziorlICQukxXVChK#3@WS!INPN$F6u!IhS# z!=p3#RH-@xWwH}>HZO&!bTE^H(P@|xJ%%U2BFIp{^8*fY@|l3qq5$i3ET}~QXeu(s zX=Jzr|G+F$>AZk{*lCyAkVpU@FwY?ljQ|SJ0F?AdyhYL&(y`8`T=9!0s z!5PUQuEs>nw4fYFIZgHLJOv_R0BnE@K{kpRoPh^(!K6$G*eJGw=)h9S`kf*x9fB69 zxKIX0XLoQMz^*>V$e}RcTCNjKg^A?ARB?GJ`0aGO%lMZmgt(K_-EEN=K1`YyX!1PyNHE<(HlhcCR$urPGALM2B=BM=BpGzJZYM8gp%7zCrn!Z2#+ zZ@53z{R=KpZPU5bDzeRN^!k(hbEktiFPma@ef~>J82>eK1Kf&!Eo?vr;AI2DG`mc52 zFz8>(!BMcE(i}FxLs)I|_lt`I^K~BJ$qFo(XEp;*Qg|P*pqVu0Kxp&BNj%_SV|W|^ zhJz7dSOOLWkcvjYfrobp7$gz`#UmgDEF6;%M + + + + + + Bitwarden + + + + + + +
+
+ + + + + + + + + diff --git a/src/services/browserMessaging.service.ts b/src/services/browserMessaging.service.ts index e5bd9b1e98..01f94269fb 100644 --- a/src/services/browserMessaging.service.ts +++ b/src/services/browserMessaging.service.ts @@ -1,8 +1,16 @@ +import { BrowserApi } from '../browser/browserApi'; +import { SafariApp } from '../browser/safariApp'; + import { MessagingService } from 'jslib/abstractions/messaging.service'; export default class BrowserMessagingService implements MessagingService { send(subscriber: string, arg: any = {}) { const message = Object.assign({}, { command: subscriber }, arg); - chrome.runtime.sendMessage(message); + if (BrowserApi.isSafariApi) { + SafariApp.sendMessageToApp(subscriber, arg); + SafariApp.sendMessageToListeners(message, 'BrowserMessagingService', null); + } else { + chrome.runtime.sendMessage(message); + } } } diff --git a/src/services/browserPlatformUtils.service.ts b/src/services/browserPlatformUtils.service.ts index 88fd5b2ee0..38bab53b43 100644 --- a/src/services/browserPlatformUtils.service.ts +++ b/src/services/browserPlatformUtils.service.ts @@ -27,7 +27,7 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService return this.deviceCache; } - if (navigator.userAgent.indexOf(' Safari/') !== -1) { + if (this.isSafariExtension()) { this.deviceCache = DeviceType.SafariExtension; } else if (navigator.userAgent.indexOf(' Firefox/') !== -1 || navigator.userAgent.indexOf(' Gecko/') !== -1) { this.deviceCache = DeviceType.FirefoxExtension; @@ -190,7 +190,13 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService } const clearing = options ? !!options.clearing : false; const clearMs: number = options && options.clearMs ? options.clearMs : null; - if (this.isFirefox() && (win as any).navigator.clipboard && (win as any).navigator.clipboard.writeText) { + if (this.isSafariExtension()) { + SafariApp.sendMessageToApp('copyToClipboard', text).then(() => { + if (!clearing && this.clipboardWriteCallback != null) { + this.clipboardWriteCallback(text, clearMs); + } + }); + } else if (this.isFirefox() && (win as any).navigator.clipboard && (win as any).navigator.clipboard.writeText) { (win as any).navigator.clipboard.writeText(text).then(() => { if (!clearing && this.clipboardWriteCallback != null) { this.clipboardWriteCallback(text, clearMs); @@ -238,7 +244,7 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService doc = options.doc; } - if (this.isSafari()) { + if (this.isSafariExtension()) { return await SafariApp.sendMessageToApp('readFromClipboard'); } else if (this.isFirefox() && (win as any).navigator.clipboard && (win as any).navigator.clipboard.readText) { return await (win as any).navigator.clipboard.readText(); @@ -305,6 +311,10 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService return false; } + private isSafariExtension(): boolean { + return (window as any).safariAppExtension === true; + } + getDefaultSystemTheme() { return this.prefersColorSchemeDark.matches ? 'dark' : 'light'; } diff --git a/src/services/browserStorage.service.ts b/src/services/browserStorage.service.ts index 9d3a6bc677..7510327d7d 100644 --- a/src/services/browserStorage.service.ts +++ b/src/services/browserStorage.service.ts @@ -1,47 +1,63 @@ -import { StorageService } from 'jslib/abstractions/storage.service'; +import { + PlatformUtilsService, + StorageService, +} from 'jslib/abstractions'; + +import { SafariApp } from '../browser/safariApp'; export default class BrowserStorageService implements StorageService { private chromeStorageApi: any; + private isSafari: boolean; - constructor() { - this.chromeStorageApi = chrome.storage.local; + constructor(platformUtilsService: PlatformUtilsService) { + this.isSafari = platformUtilsService.isSafari(); + if (!this.isSafari) { + this.chromeStorageApi = chrome.storage.local; + } } async get(key: string): Promise { - return new Promise((resolve) => { - this.chromeStorageApi.get(key, (obj: any) => { - if (obj != null && obj[key] != null) { - resolve(obj[key] as T); - return; - } - resolve(null); + if (this.isSafari) { + const obj = await SafariApp.sendMessageToApp('storage_get', key); + return obj == null ? null : JSON.parse(obj) as T; + } else { + return new Promise((resolve) => { + this.chromeStorageApi.get(key, (obj: any) => { + if (obj != null && obj[key] != null) { + resolve(obj[key] as T); + return; + } + resolve(null); + }); }); - }); + } } async save(key: string, obj: any): Promise { - if (obj == null) { - // Fix safari not liking null in set + const keyedObj = { [key]: obj }; + if (this.isSafari) { + await SafariApp.sendMessageToApp('storage_save', JSON.stringify({ + key: key, + obj: JSON.stringify(obj), + })); + } else { + return new Promise((resolve) => { + this.chromeStorageApi.set(keyedObj, () => { + resolve(); + }); + }); + } + } + + async remove(key: string): Promise { + if (this.isSafari) { + await SafariApp.sendMessageToApp('storage_remove', key); + } else { return new Promise((resolve) => { this.chromeStorageApi.remove(key, () => { resolve(); }); }); } - - const keyedObj = { [key]: obj }; - return new Promise((resolve) => { - this.chromeStorageApi.set(keyedObj, () => { - resolve(); - }); - }); - } - - async remove(key: string): Promise { - return new Promise((resolve) => { - this.chromeStorageApi.remove(key, () => { - resolve(); - }); - }); } } diff --git a/src/services/i18n.service.ts b/src/services/i18n.service.ts index 542dbb6f35..4eb880424d 100644 --- a/src/services/i18n.service.ts +++ b/src/services/i18n.service.ts @@ -1,11 +1,19 @@ import { I18nService as BaseI18nService } from 'jslib/services/i18n.service'; +import { BrowserApi } from '../browser/browserApi'; +import { SafariApp } from '../browser/safariApp'; + export default class I18nService extends BaseI18nService { constructor(systemLanguage: string) { - super(systemLanguage, null, async (formattedLocale: string) => { - // Deprecated - const file = await fetch(this.localesDirectory + formattedLocale + '/messages.json'); - return await file.json(); + super(systemLanguage, BrowserApi.isSafariApi ? 'safari' : null, async (formattedLocale: string) => { + if (BrowserApi.isSafariApi) { + await SafariApp.sendMessageToApp('getLocaleStrings', formattedLocale); + return (window as any).bitwardenLocaleStrings; + } else { + // Deprecated + const file = await fetch(this.localesDirectory + formattedLocale + '/messages.json'); + return await file.json(); + } }); this.supportedTranslationLocales = [