diff --git a/.gitignore b/.gitignore index ec4ccf74ded..ba56421cb76 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ focalboard*.db mac/resources/config.json mac/temp mac/dist +mac/*.xcodeproj/**/xcuserdata linux/bin linux/dist linux/temp diff --git a/mac/Focalboard/AppDelegate.swift b/mac/Focalboard/AppDelegate.swift index 8e86221893f..5c2440cd918 100644 --- a/mac/Focalboard/AppDelegate.swift +++ b/mac/Focalboard/AppDelegate.swift @@ -32,10 +32,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { @IBAction func openNewWindow(_ sender: Any?) { let mainStoryBoard = NSStoryboard(name: "Main", bundle: nil) - let tabViewController = mainStoryBoard.instantiateController(withIdentifier: "ViewController") as? ViewController let windowController = mainStoryBoard.instantiateController(withIdentifier: "WindowController") as! NSWindowController windowController.showWindow(self) - windowController.contentViewController = tabViewController } private func showWhatsNewDialogIfNeeded() { diff --git a/mac/Focalboard/ViewController.swift b/mac/Focalboard/ViewController.swift index 8bd5bfe9ee1..bfc89c51576 100644 --- a/mac/Focalboard/ViewController.swift +++ b/mac/Focalboard/ViewController.swift @@ -4,10 +4,18 @@ import Cocoa import WebKit +private let messageHandlerName = "callback" + +private enum MessageType: String { + case didImportUserSettings + case didNotImportUserSettings +} + class ViewController: NSViewController, WKUIDelegate, - WKNavigationDelegate { + WKNavigationDelegate, + WKScriptMessageHandler { @IBOutlet var webView: WKWebView! private var refreshWebViewOnLoad = true @@ -19,14 +27,15 @@ class ViewController: webView.navigationDelegate = self webView.uiDelegate = self webView.isHidden = true + webView.configuration.userContentController.add(self, name: messageHandlerName) clearWebViewCache() // Load the home page if the server was started, otherwise wait until it has let appDelegate = NSApplication.shared.delegate as! AppDelegate if (appDelegate.isServerStarted) { - self.updateSessionToken() - self.loadHomepage() + updateSessionTokenAndUserSettings() + loadHomepage() } // Do any additional setup after loading the view. @@ -38,6 +47,11 @@ class ViewController: self.view.window?.makeFirstResponder(self.webView) } + override func viewWillDisappear() { + super.viewWillDisappear() + persistUserSettings() + } + override var representedObject: Any? { didSet { // Update the view, if already loaded. @@ -64,20 +78,70 @@ class ViewController: @objc func onServerStarted() { NSLog("onServerStarted") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.updateSessionToken() + self.updateSessionTokenAndUserSettings() self.loadHomepage() } } - private func updateSessionToken() { + private func persistUserSettings() { + let semaphore = DispatchSemaphore(value: 0) + + // Convert to base64 to avoid escaping issues when later inserting the exported settings into the import script + webView.evaluateJavaScript("window.btoa(Focalboard.exportUserSettings())") { result, error in + defer { semaphore.signal() } + guard let base64 = result as? String, let data = Data(base64Encoded: base64), let decoded = String(data: data, encoding: .utf8) else { + NSLog("Failed to export user settings: \(error?.localizedDescription ?? "?")") + return + } + UserDefaults.standard.set(base64, forKey: "localStorage") + NSLog("Persisted user settings: \(decoded)") + } + + // During shutdown the system grants us about 5 seconds to clean up and store user data + let timeout = DispatchTime.now() + .seconds(3) + var result: DispatchTimeoutResult? + + // Busy wait because evaluateJavaScript can only be called from *and* signals on the main thread + while (result != .success && .now() < timeout) { + result = semaphore.wait(timeout: .now()) + RunLoop.current.run(mode: .default, before: Date()) + } + + if result == .timedOut { + NSLog("Timed out trying to persist user settings") + } + } + + private func updateSessionTokenAndUserSettings() { let appDelegate = NSApplication.shared.delegate as! AppDelegate - let script = WKUserScript( + let sessionTokenScript = WKUserScript( source: "localStorage.setItem('focalboardSessionId', '\(appDelegate.sessionToken)');", injectionTime: .atDocumentStart, forMainFrameOnly: true ) + let base64 = UserDefaults.standard.string(forKey: "localStorage") ?? "" + let userSettingsScript = WKUserScript( + source: """ + const settings = window.atob("\(base64)"); + if (typeof(Focalboard) !== 'undefined') { + if (Focalboard.importUserSettings(settings)) { + window.webkit.messageHandlers.\(messageHandlerName).postMessage({ + type: '\(MessageType.didImportUserSettings.rawValue)', + settings: settings + }); + } else { + window.webkit.messageHandlers.\(messageHandlerName).postMessage({ + type: '\(MessageType.didNotImportUserSettings.rawValue)' + }); + } + } + """, + injectionTime: .atDocumentEnd, + forMainFrameOnly: true + ) webView.configuration.userContentController.removeAllUserScripts() - webView.configuration.userContentController.addUserScript(script) + webView.configuration.userContentController.addUserScript(sessionTokenScript) + webView.configuration.userContentController.addUserScript(userSettingsScript) } private func loadHomepage() { @@ -219,5 +283,17 @@ class ViewController: @IBAction func navigateToHome(_ sender: NSObject) { loadHomepage() } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard let body = message.body as? [String: Any], let rawType = body["type"] as? String, let type = MessageType(rawValue: rawType) else { + return + } + switch type { + case .didImportUserSettings: + NSLog("Imported user settings: \(body["settings"] ?? "?")") + case .didNotImportUserSettings: + NSLog("Skipped importing stale, empty or invalid user settings") + } + } } diff --git a/webapp/src/userSettings.ts b/webapp/src/userSettings.ts new file mode 100644 index 00000000000..013199c098c --- /dev/null +++ b/webapp/src/userSettings.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +const keys = ['language', 'theme', 'lastBoardId', 'lastViewId', 'emoji-mart.last', 'emoji-mart.frequently'] + +export function exportUserSettings(): string { + const settings = Object.fromEntries(keys.map((key) => [key, localStorage.getItem(key)])) + settings.timestamp = `${Date.now()}` + return JSON.stringify(settings) +} + +export function importUserSettings(json: string): boolean { + const settings = parseUserSettings(json) + const timestamp = settings.timestamp + const lastTimestamp = localStorage.getItem('timestamp') + if (!timestamp || (lastTimestamp && Number(timestamp) <= Number(lastTimestamp))) { + return false + } + for (const [key, value] of Object.entries(settings)) { + localStorage.setItem(key, value as string) + } + location.reload() + return true +} + +function parseUserSettings(json: string): any { + try { + return JSON.parse(json) + } catch (e) { + return {} + } +} diff --git a/webapp/webpack.common.js b/webapp/webpack.common.js index 8214f8ca2a4..5567d3a8af0 100644 --- a/webapp/webpack.common.js +++ b/webapp/webpack.common.js @@ -92,10 +92,9 @@ function makeCommonConfig() { publicPath: '{{.BaseURL}}/', }), ], - entry: { - main: './src/main.tsx', - }, + entry: ['./src/main.tsx', './src/userSettings.ts'], output: { + library: "Focalboard", filename: 'static/[name].js', path: outpath, },